Estiaje documentation
Estiaje takes what happened in your code and what matters to your business and puts them in one river. This page is everything you need: it takes fifteen minutes to read and there is no hidden part two.
Install
npm install @estiaje/sdk
Zero dependencies. Node 18 or later. It works the same in an API, in a bundled front end and in a lambda. Your service isn’t Node? See Does it work with your stack?, right below.
Does it work with your stack?
With Node you use the official SDK. With any other language it’s the same
POST to /v1/events, and below is the recipe for each one,
ready to copy: where the code goes, how not to slow your app down and how to follow
the thread of an order from one service to the next.
| Stack | How | Events and watched processes | Deploys | The journey across services | Tested |
|---|---|---|---|---|---|
| Node.js · Express, NestJS, Fastify, Next.js | Official SDK | Yes | On its own at startup, or from CI | Yes, with the context | Tested in CI |
| Bun · Deno | Official SDK | Yes | From CI | Yes, with the context | Not tested yet |
| Python · Django, Flask, FastAPI | HTTP recipe | Yes | From CI | Automatic with the middleware | Tested in CI |
| PHP · Laravel, WordPress | HTTP recipe | Yes | From CI | Automatic with the middleware | Tested in CI |
| Java · Spring Boot | HTTP recipe | Yes | From CI | Automatic with the filter | Tested in CI |
| C# · ASP.NET Core | HTTP recipe | Yes | From CI | Automatic with the middleware | Tested in CI |
| Go · net/http, Gin | HTTP recipe | Yes | From CI | Automatic with the middleware | Tested in CI |
| Ruby · Rails, Sinatra | HTTP recipe | Yes | From CI | Automatic with the middleware | Tested in CI |
| Serverless · Vercel, AWS Lambda, Cloudflare Workers | SDK or HTTP, waiting at the end | Yes | From CI | Passing traceparent | Not tested yet |
| No-code · n8n, Make, Zapier | HTTP request | Yes | — | — | Not tested yet |
| CI · GitHub Actions, GitLab, Bitbucket, CircleCI, Vercel | npx @estiaje/sdk desplegado | — | Yes, with who and what | — | Tested in CI |
Tested in CI: on every change, an automated test runs the recipe, exactly as
it appears on this page, against Estiaje: the event arrives, and the thread comes in
and goes out with the same trace_id. The framework-specific lines
(registering the middleware) are not run. Not tested yet: it should work, but
we haven’t checked it yet.
What each recipe does
- Sends without slowing your app down. Sending happens in the background,
with a 2-second timeout. If Estiaje doesn’t answer, your app doesn’t even notice.
Without
ESTIAJE_TOKENnothing goes out over the network. - Follows the thread on its own. When a request comes in, the middleware reads
the standard
traceparentheader (the same one OpenTelemetry uses), or creates one if it’s missing. Everything you emit during that request carries itstrace_idwithout you writing it, and whatever goes out to your other services carriestraceparentwith the same thread. That’s how the journey is drawn end to end. - No dependencies. One file you copy into your project, plus one or two lines in your framework.
Node.js · Express, NestJS, Fastify, Next.js
The official SDK: Install, your first event and, for the thread across services, Context and lineage.
Python · Django, Flask, FastAPI
Copy estiaje.py into your project. Python 3.8 or later.
# estiaje.py: copy this file into your project. No dependencies.
import contextvars, json, os, re, secrets, threading, urllib.request
URL = (os.environ.get("ESTIAJE_URL") or "https://estiaje.mx") + "/v1/events"
SERVICIO = "mi-api"
_hilo = contextvars.ContextVar("estiaje_hilo", default=None)
_envios = []
_TP = re.compile(r"^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$")
def empezar_hilo(traceparent=None):
"""When a request comes in: take the thread from the header or create a new one."""
m = _TP.match((traceparent or "").strip().lower())
hilo = m.group(1) if m and m.group(1) != "0" * 32 else secrets.token_hex(16)
_hilo.set(hilo)
return hilo
def traceparent():
"""For calls going out to another service: the same thread, a new step."""
return "00-%s-%s-01" % (_hilo.get() or secrets.token_hex(16), secrets.token_hex(8))
def evento(name, attrs=None, kind="event"):
"""Sends the event in the background: it never slows down or breaks your app."""
if not os.environ.get("ESTIAJE_TOKEN"):
return # without a key nothing goes out over the network
ev = {"service": SERVICIO, "kind": kind, "name": name, "attrs": attrs or {}}
if _hilo.get():
ev["trace_id"] = _hilo.get()
envio = threading.Thread(target=_mandar, args=([ev],))
envio.start()
_envios[:] = [e for e in _envios if e.is_alive()] + [envio]
def esperar():
"""In a lambda, before returning: let pending sends go out (2 s at most)."""
for e in list(_envios):
e.join()
def _mandar(lote):
try:
req = urllib.request.Request(URL, data=json.dumps(lote).encode(), headers={
"content-type": "application/json",
"authorization": "Bearer " + os.environ["ESTIAJE_TOKEN"]})
urllib.request.urlopen(req, timeout=2).close()
except Exception:
pass # if Estiaje doesn't answer, your app keeps going
Wherever something happens:
import estiaje
estiaje.evento("pedido.creado", {"order_id": pedido.id, "monto": pedido.total})
Django: the thread, in a middleware.
# miapp/middleware.py (and in settings.MIDDLEWARE: "miapp.middleware.hilo_estiaje")
import estiaje
def hilo_estiaje(get_response):
def middleware(request):
estiaje.empezar_hilo(request.headers.get("traceparent"))
return get_response(request)
return middleware
Flask:
@app.before_request
def hilo_estiaje():
estiaje.empezar_hilo(request.headers.get("traceparent"))
FastAPI: the thread in a middleware, and the event with BackgroundTasks
so it goes out after responding.
from fastapi import BackgroundTasks, FastAPI, Request
import estiaje
app = FastAPI()
@app.middleware("http")
async def hilo_estiaje(request: Request, call_next):
estiaje.empezar_hilo(request.headers.get("traceparent"))
return await call_next(request)
@app.post("/pedidos")
async def crear(p: Pedido, tareas: BackgroundTasks):
...
tareas.add_task(estiaje.evento, "pedido.creado", {"order_id": p.id, "monto": p.total})
When calling another of your services, pass the thread along:
requests.get(url, headers={"traceparent": estiaje.traceparent()}, timeout=5)
In a script or a cron job nothing else is needed: Python waits for the send before exiting.
PHP · Laravel, WordPress
Copy estiaje.php into your project. PHP 7.4 or later, with the
curl extension. Events are collected and sent at the end, once your user
already has their response (with PHP-FPM).
<?php
// estiaje.php: copy it into your project (in Laravel, app/Estiaje.php). No dependencies.
final class Estiaje
{
const SERVICIO = 'mi-api';
private static ?string $hilo = null;
private static array $lote = [];
/** When a request comes in: take the thread from the header or create a new one. */
public static function empezarHilo(?string $traceparent): string
{
$tp = strtolower(trim((string) $traceparent));
$ok = preg_match('/^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/', $tp, $m);
self::$hilo = $ok && $m[1] !== str_repeat('0', 32) ? $m[1] : bin2hex(random_bytes(16));
return self::$hilo;
}
/** For calls going out to another service: the same thread, a new step. */
public static function traceparent(): string
{
return '00-' . (self::$hilo ?? bin2hex(random_bytes(16))) . '-' . bin2hex(random_bytes(8)) . '-01';
}
/** Collects the event; it's sent at the end, once your user has their response. */
public static function evento(string $name, array $attrs = [], string $kind = 'event'): void
{
if (!getenv('ESTIAJE_TOKEN')) return; // without a key nothing goes out over the network
$ev = ['service' => self::SERVICIO, 'kind' => $kind, 'name' => $name, 'attrs' => (object) $attrs];
if (self::$hilo) $ev['trace_id'] = self::$hilo;
if (!self::$lote) register_shutdown_function([self::class, 'mandar']);
self::$lote[] = $ev;
}
public static function mandar(): void
{
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
$lote = self::$lote;
self::$lote = [];
if (!$lote) return;
$ch = curl_init((getenv('ESTIAJE_URL') ?: 'https://estiaje.mx') . '/v1/events');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['content-type: application/json',
'authorization: Bearer ' . getenv('ESTIAJE_TOKEN')],
CURLOPT_POSTFIELDS => json_encode($lote),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 2000,
]);
curl_exec($ch); // if Estiaje doesn't answer, your app keeps going
}
}
Wherever something happens:
Estiaje::evento('pedido.creado', ['order_id' => $pedido->id, 'monto' => $pedido->total]);
Laravel: the thread, in a middleware.
// app/Http/Middleware/HiloEstiaje.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class HiloEstiaje
{
public function handle(Request $request, Closure $next)
{
\Estiaje::empezarHilo($request->header('traceparent'));
return $next($request);
}
}
// bootstrap/app.php (Laravel 11+; before that, in $middleware in app/Http/Kernel.php)
->withMiddleware(fn ($middleware) => $middleware->append(\App\Http\Middleware\HiloEstiaje::class))
When calling another of your services with Laravel’s HTTP client, pass the thread along:
Http::withHeaders(['traceparent' => \Estiaje::traceparent()])->get($url);
Prefer to send the event with Laravel’s HTTP client? Make it go out after responding:
// in config/services.php: 'estiaje' => ['token' => env('ESTIAJE_TOKEN')]
dispatch(fn () => Http::timeout(2)->withToken(config('services.estiaje.token'))
->post('https://estiaje.mx/v1/events', [[
'service' => 'mi-api', 'kind' => 'event', 'name' => 'pedido.creado',
'attrs' => ['order_id' => $pedido->id, 'monto' => $pedido->total],
]]))->afterResponse();
estiaje.php reads the key with getenv. With
php artisan config:cache, Laravel no longer loads the .env:
set ESTIAJE_TOKEN as a server environment variable.
WordPress, in your plugin or in functions.php:
require_once __DIR__ . '/estiaje.php';
add_action('init', fn () => Estiaje::empezarHilo($_SERVER['HTTP_TRACEPARENT'] ?? null));
// when calling another service
wp_remote_get($url, ['headers' => ['traceparent' => Estiaje::traceparent()]]);
Java · Spring Boot
Copy Estiaje.java into your project. Java 17 or later.
// Estiaje.java: copy it into your project (with your package). No dependencies, Java 17+.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class Estiaje {
static final String SERVICIO = "mi-api";
static final String TOKEN = System.getenv("ESTIAJE_TOKEN");
static final String BASE = System.getenv("ESTIAJE_URL");
static final String URL = (BASE == null || BASE.isEmpty() ? "https://estiaje.mx" : BASE) + "/v1/events";
static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)).build();
static final ThreadLocal<String> HILO = new ThreadLocal<>();
static final Pattern TP = Pattern.compile("^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$");
static final SecureRandom AZAR = new SecureRandom();
/** When a request comes in: take the thread from the header or create a new one. */
public static String empezarHilo(String traceparent) {
Matcher m = TP.matcher(traceparent == null ? "" : traceparent.trim().toLowerCase());
String hilo = m.matches() && !m.group(1).equals("0".repeat(32)) ? m.group(1) : hex(16);
HILO.set(hilo);
return hilo;
}
/** When the request ends: the Java thread is reused for the next one. */
public static void terminarHilo() {
HILO.remove();
}
/** For calls going out to another service: the same thread, a new step. */
public static String traceparent() {
String hilo = HILO.get();
return "00-" + (hilo != null ? hilo : hex(16)) + "-" + hex(8) + "-01";
}
/** Sends the event in the background: it never slows down or breaks your app. */
public static CompletableFuture<Void> evento(String name, Map<String, ?> attrs) {
try {
if (TOKEN == null || TOKEN.isEmpty()) return CompletableFuture.completedFuture(null);
StringBuilder j = new StringBuilder("[{\"service\":" + q(SERVICIO) + ",\"kind\":\"event\",\"name\":" + q(name));
if (HILO.get() != null) j.append(",\"trace_id\":").append(q(HILO.get()));
j.append(",\"attrs\":{");
String coma = "";
for (Map.Entry<String, ?> a : attrs.entrySet()) {
Object v = a.getValue();
j.append(coma).append(q(a.getKey())).append(':')
.append(v instanceof Number || v instanceof Boolean ? v.toString() : q(String.valueOf(v)));
coma = ",";
}
j.append("}}]");
HttpRequest req = HttpRequest.newBuilder(URI.create(URL)).timeout(Duration.ofSeconds(2))
.header("content-type", "application/json")
.header("authorization", "Bearer " + TOKEN)
.POST(HttpRequest.BodyPublishers.ofString(j.toString())).build();
return HTTP.sendAsync(req, HttpResponse.BodyHandlers.discarding()).handle((r, e) -> null);
} catch (RuntimeException e) {
return CompletableFuture.completedFuture(null); // if something fails, your app keeps going
}
}
static String q(String s) {
StringBuilder b = new StringBuilder("\"");
for (char c : s.toCharArray()) {
if (c == '"' || c == '\\') b.append('\\').append(c);
else if (c < 0x20) b.append(String.format("\\u%04x", (int) c));
else b.append(c);
}
return b.append('"').toString();
}
static String hex(int bytes) {
byte[] b = new byte[bytes];
AZAR.nextBytes(b);
StringBuilder s = new StringBuilder();
for (byte x : b) s.append(String.format("%02x", x));
return s.toString();
}
}
Wherever something happens:
Estiaje.evento("pedido.creado", Map.of("order_id", pedido.getId(), "monto", pedido.getTotal()));
Spring Boot: the thread, in a filter.
@Component
public class HiloEstiaje extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
Estiaje.empezarHilo(req.getHeader("traceparent"));
try {
chain.doFilter(req, res);
} finally {
Estiaje.terminarHilo();
}
}
}
When calling another of your services (with RestClient), pass the thread along:
RestClient http = RestClient.builder()
.requestInterceptor((req, cuerpo, sigue) -> {
req.getHeaders().set("traceparent", Estiaje.traceparent());
return sigue.execute(req, cuerpo);
})
.build();
In a program that exits right away, wait for the send:
Estiaje.evento(…).join().
C# · ASP.NET Core
Copy Estiaje.cs into your project. .NET 6 or later.
// Estiaje.cs: copy it into your project. No packages, .NET 6 or later.
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
public static class Estiaje
{
const string Servicio = "mi-api";
static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(2) };
static readonly string Base = Environment.GetEnvironmentVariable("ESTIAJE_URL") ?? "";
static readonly string Url = (Base.Length > 0 ? Base : "https://estiaje.mx") + "/v1/events";
static readonly AsyncLocal<string?> Hilo = new();
static readonly Regex Formato = new("^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$");
/// When a request comes in: take the thread from the header or create a new one.
public static string EmpezarHilo(string? traceparent)
{
var m = Formato.Match((traceparent ?? "").Trim().ToLowerInvariant());
Hilo.Value = m.Success && m.Groups[1].Value != new string('0', 32) ? m.Groups[1].Value : Azar(16);
return Hilo.Value;
}
/// For calls going out to another service: the same thread, a new step.
public static string Traceparent() => $"00-{Hilo.Value ?? Azar(16)}-{Azar(8)}-01";
/// Sends the event in the background: it never slows down or breaks your app. No need to await it.
public static Task Evento(string name, object? attrs = null)
{
var token = Environment.GetEnvironmentVariable("ESTIAJE_TOKEN");
if (string.IsNullOrEmpty(token)) return Task.CompletedTask; // without a key nothing goes out
var ev = new Dictionary<string, object?>
{
["service"] = Servicio, ["kind"] = "event", ["name"] = name, ["attrs"] = attrs ?? new { },
};
if (Hilo.Value is { } hilo) ev["trace_id"] = hilo;
var req = new HttpRequestMessage(HttpMethod.Post, Url) { Content = JsonContent.Create(new[] { ev }) };
req.Headers.TryAddWithoutValidation("authorization", "Bearer " + token);
return Http.SendAsync(req).ContinueWith(t => // if Estiaje doesn't answer, your app keeps going
{
if (t.IsCompletedSuccessfully) t.Result.Dispose(); else _ = t.Exception;
});
}
static string Azar(int bytes) => Convert.ToHexString(RandomNumberGenerator.GetBytes(bytes)).ToLowerInvariant();
}
/// Puts traceparent on whatever goes out through an HttpClient to your other services.
public class EstiajeSaliente : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
{
req.Headers.Remove("traceparent");
req.Headers.TryAddWithoutValidation("traceparent", Estiaje.Traceparent());
return base.SendAsync(req, ct);
}
}
In Program.cs: the thread in a middleware, and your HttpClients
passing it on to your other services.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<EstiajeSaliente>();
builder.Services.AddHttpClient("inventario").AddHttpMessageHandler<EstiajeSaliente>();
var app = builder.Build();
app.Use(async (ctx, next) =>
{
Estiaje.EmpezarHilo(ctx.Request.Headers["traceparent"]);
await next();
});
Wherever something happens (no await needed):
Estiaje.Evento("pedido.creado", new { order_id = pedido.Id, monto = pedido.Total });
In a console app that exits right away, do await it:
await Estiaje.Evento(…).
Go · net/http, Gin
Copy estiaje.go into your project, in its own estiaje package. Go 1.18
or later.
// estiaje.go: copy it into your project (package estiaje). No dependencies.
package estiaje
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
)
const servicio = "mi-api"
var (
cliente = &http.Client{Timeout: 2 * time.Second}
formato = regexp.MustCompile(`^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$`)
pendientes sync.WaitGroup
)
type claveHilo struct{}
// ConHilo: when a request comes in, take the thread from the header or create a new one.
func ConHilo(ctx context.Context, traceparent string) context.Context {
hilo := azar(16)
if m := formato.FindStringSubmatch(strings.ToLower(strings.TrimSpace(traceparent))); m != nil && m[1] != strings.Repeat("0", 32) {
hilo = m[1]
}
return context.WithValue(ctx, claveHilo{}, hilo)
}
// Middleware for net/http (works the same with chi or gorilla/mux).
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(ConHilo(r.Context(), r.Header.Get("traceparent"))))
})
}
func Hilo(ctx context.Context) string {
h, _ := ctx.Value(claveHilo{}).(string)
return h
}
// Traceparent: for calls going out to another service. The same thread, a new step.
func Traceparent(ctx context.Context) string {
h := Hilo(ctx)
if h == "" {
h = azar(16)
}
return "00-" + h + "-" + azar(8) + "-01"
}
// Transporte puts traceparent on everything that goes out through an http.Client:
// &http.Client{Transport: estiaje.Transporte{}}, and the request with its ctx.
type Transporte struct{ Base http.RoundTripper }
func (t Transporte) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
r.Header.Set("traceparent", Traceparent(r.Context()))
if t.Base == nil {
return http.DefaultTransport.RoundTrip(r)
}
return t.Base.RoundTrip(r)
}
// Evento sends it in the background: it never slows down or breaks your app.
func Evento(ctx context.Context, name string, attrs map[string]any) {
token := os.Getenv("ESTIAJE_TOKEN")
if token == "" {
return // without a key nothing goes out over the network
}
if attrs == nil {
attrs = map[string]any{}
}
ev := map[string]any{"service": servicio, "kind": "event", "name": name, "attrs": attrs}
if h := Hilo(ctx); h != "" {
ev["trace_id"] = h
}
cuerpo, err := json.Marshal([]any{ev})
if err != nil {
return
}
url := os.Getenv("ESTIAJE_URL")
if url == "" {
url = "https://estiaje.mx"
}
pendientes.Add(1)
go func() {
defer pendientes.Done()
req, err := http.NewRequest("POST", url+"/v1/events", bytes.NewReader(cuerpo))
if err != nil {
return
}
req.Header.Set("content-type", "application/json")
req.Header.Set("authorization", "Bearer "+token)
if res, err := cliente.Do(req); err == nil {
res.Body.Close()
}
}()
}
// Esperar: in a program that exits right away (a cron job), call it before exiting.
func Esperar() { pendientes.Wait() }
func azar(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
net/http (and chi, gorilla/mux): wrap your router, and use the
Transporte for outgoing calls.
http.ListenAndServe(":8080", estiaje.Middleware(mux))
// wherever something happens, with the request's context
estiaje.Evento(r.Context(), "pedido.creado", map[string]any{"order_id": p.ID, "monto": p.Total})
// when calling another of your services
cliente := &http.Client{Transport: estiaje.Transporte{}}
req, _ := http.NewRequestWithContext(r.Context(), "GET", url, nil)
cliente.Do(req)
Gin:
r.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(estiaje.ConHilo(c.Request.Context(), c.GetHeader("traceparent")))
c.Next()
})
In a program that exits right away (a cron job), call estiaje.Esperar()
before exiting.
Ruby · Rails, Sinatra
Copy estiaje.rb into your project. Ruby 2.7 or later, no gems.
# estiaje.rb: copy it into your project (in Rails, lib/estiaje.rb). No gems.
require "json"
require "net/http"
require "securerandom"
module Estiaje
SERVICIO = "mi-api"
FORMATO = /\A[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}\z/
@envios = []
@candado = Mutex.new
# When a request comes in: take the thread from the header or create a new one.
def self.empezar_hilo(traceparent)
m = FORMATO.match(traceparent.to_s.strip.downcase)
Thread.current[:estiaje_hilo] = m && m[1] != "0" * 32 ? m[1] : SecureRandom.hex(16)
end
def self.hilo
Thread.current[:estiaje_hilo]
end
# For calls going out to another service: the same thread, a new step.
def self.traceparent
"00-#{hilo || SecureRandom.hex(16)}-#{SecureRandom.hex(8)}-01"
end
# Sends the event in the background: it never slows down or breaks your app.
def self.evento(name, attrs = {}, kind: "event")
token = ENV["ESTIAJE_TOKEN"].to_s
return if token.empty? # without a key nothing goes out over the network
ev = { service: SERVICIO, kind: kind, name: name, attrs: attrs }
ev[:trace_id] = hilo if hilo
base = ENV["ESTIAJE_URL"].to_s.empty? ? "https://estiaje.mx" : ENV["ESTIAJE_URL"]
envio = Thread.new do
uri = URI("#{base}/v1/events")
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
open_timeout: 1, read_timeout: 2, write_timeout: 2) do |http|
http.post(uri.path, [ev].to_json,
"content-type" => "application/json", "authorization" => "Bearer #{token}")
end
rescue StandardError
nil # if Estiaje doesn't answer, your app keeps going
end
@candado.synchronize { @envios.select!(&:alive?); @envios << envio }
end
# In a script that exits right away (a cron job, a rake task), call it before exiting.
def self.esperar
@candado.synchronize { @envios.dup }.each(&:join)
end
# The Rack middleware: Rails, Sinatra, Hanami.
class Middleware
def initialize(app)
@app = app
end
def call(env)
Estiaje.empezar_hilo(env["HTTP_TRACEPARENT"])
@app.call(env)
ensure
Thread.current[:estiaje_hilo] = nil
end
end
end
Rails, in config/application.rb (in Sinatra:
use Estiaje::Middleware):
require_relative "../lib/estiaje" # …inside class Application: config.middleware.use Estiaje::Middleware
Wherever something happens, and when calling another of your services:
Estiaje.evento("pedido.creado", { order_id: pedido.id, monto: pedido.total })
req = Net::HTTP::Get.new(uri)
req["traceparent"] = Estiaje.traceparent
In a script or a rake task, call Estiaje.esperar before exiting.
Serverless · Vercel, AWS Lambda, Cloudflare Workers
A serverless function is frozen as soon as it returns its response, and whatever was about to go out never does. That’s why, here, you do wait for the send at the end.
- Vercel and Lambda with Node:
await estiaje.flush()before returning the response. - Lambda with the recipes above:
estiaje.esperar()in Python,estiaje.Esperar()in Go,Estiaje.esperarin Ruby,.join()in Java andawaitin C#. - Cloudflare Workers:
ctx.waitUntillets the send finish after responding, without making your user wait.
export default {
async fetch(request, env, ctx) {
// the thread: the one from the traceparent header, or a new one
const m = /^[0-9a-f]{2}-([0-9a-f]{32})-/.exec(request.headers.get('traceparent') ?? '');
const trace_id = m ? m[1] : crypto.randomUUID().replaceAll('-', '');
ctx.waitUntil(fetch('https://estiaje.mx/v1/events', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${env.ESTIAJE_TOKEN}` },
body: JSON.stringify([{ service: 'mi-worker', kind: 'event', name: 'pedido.creado', trace_id }]),
}).catch(() => {})); // if Estiaje doesn't answer, your worker keeps going
return new Response('ok');
},
};
No-code · n8n, Make, Zapier
One HTTP request step at the end of your flow: in n8n, the HTTP Request node; in Make, HTTP › Make a request; in Zapier, Webhooks by Zapier › Custom Request.
| Field | Value |
|---|---|
| Method | POST |
| URL | https://estiaje.mx/v1/events |
| Headers | authorization: Bearer ck_live_… and content-type: application/json |
| Body (JSON) | the one below (this is how it’s written in n8n), with the data from the previous step |
[{"service": "n8n", "kind": "event", "name": "pedido.creado",
"attrs": {"order_id": "{{ $json.id }}", "monto": {{ $json.total }}}}]
Keep the key in the tool’s credentials, not written into the step. Set the step to continue on failure: if Estiaje doesn’t answer, your flow doesn’t stop.
CI · GitHub Actions, GitLab, Bitbucket, CircleCI, Vercel
One line at the end of your pipeline reports every deploy, with who and what:
npx @estiaje/sdk desplegado. It works no matter what language your
service is written in. The details are in Deploys.
Want an SDK for your language, with an in-memory queue and retries like the Node one? Write to us at hola@estiaje.mx: the first one to ask decides which comes next.
Your first event
Get your key
Create your organization on the sign-up screen. It
gives you a ck_live_… key that is shown only once:
it’s stored encrypted, so if you lose it you revoke it and generate another one.
Point the SDK
ESTIAJE_TOKEN=ck_live_…
A single line: with the key, the SDK already sends to https://estiaje.mx.
Without ESTIAJE_TOKEN nothing goes out over the network — every function
exists and prints to the console. You can instrument today and add the key later.
Emit
import { estiajeFromEnv } from '@estiaje/sdk'; // ONE per process. Not one per request, not one per file. export const estiaje = estiajeFromEnv({ service: 'mi-api' }); estiaje.log('usecase.ok', { usecase: 'GetVisits', ms: 128 });
Open the river and you’ll see the event land in under two seconds.
The three verbs
The difference isn’t one of style: it decides how long each thing lives and which questions you’ll be able to answer a year from now.
| Verb | What for | Lives |
|---|---|---|
estiaje.log() | What happened. Diagnostics, timings, internal steps. | Gets deleted: it’s noise with an expiry date. |
estiaje.event() | What matters to the business: a visit started, a planogram changed. | Kept. |
estiaje.outcome() | What ENDS a process: the sale was processed, the task closed. | Kept. It’s where the river flows out. |
There are also warn(), error() and fatal():
they’re log with a severity.
log(), you’re not missing anything. The other two verbs
show up the day you want to answer "how many orders ended with the payment
processed?" without opening the database.
Name things by what happened, not by where it happened
order.created, not PedidoController.done. The name
survives the refactor; the file path doesn’t. And in business events
always include the identifier of the thing (task_id,
order_id, sn): it’s what lets the start be paired with
the end.
The logs you already write
If your app already logs with console.log, pino or winston, change
nothing: one line and those logs reach Estiaje, where you search them by text
and filter by service, severity or any tag. Included in every plan, starting
with Free. Since SDK 0.7.0.
// console.log, info, warn and error, as you already write them
estiaje.capturarConsola();
// pino: to the console and to Estiaje
const logger = pino(pino.multistream([
{ stream: process.stdout },
{ stream: estiaje.destino() },
]));
// winston: one more transport
logger.add(new winston.transports.Stream({ stream: estiaje.destino() }));
- Your console prints exactly as before. Estiaje only listens.
- Severity is kept:
console.erroror a pinoerrorlevel arrives as an error, with its stack. - pino and winston fields (
{ order: 'A-1042' }) arrive as tags you can filter on. - A cap of 20 lines per second (
{ porSegundo }to change it): a loop that prints a thousand won’t eat the queue, and the ones left out are counted. If the queue fills up, logs go first, never an event or an outcome. console.debugisn’t sent by default:capturarConsola({ metodos: ['debug', 'log', 'info', 'warn', 'error'] }).
Logs are kept 3 days on Free, 7 on Inicio, 30 on Pro and 90 on Max. Errors are
kept longer: 14 days, 90 days or a year (see Retention and
quotas). From another language, send kind: "log" through the
HTTP API.
Context and lineage
The thread, the entity, the actor and the request’s tags aren’t written in every call. They’re declared once and attach themselves to everything you emit.
estiajeFromEnv({ service: 'api-pedidos', context: () => ({ trace_id: hiloActual(), // from the interceptor / AsyncLocalStorage entity: 'sucursal:polanco', // the anchor: the real-world thing actor: usuarioActual(), attrs: { session_id, module: 'checkout' }, geo: posicionActual(), }), })
If the front end sends its traceparent header and your interceptor
respects it, the same trace goes from the user’s click all the way to the lambda
without any use case ever writing a trace_id. That’s what draws
the journey.
context() hook is called on every emit. If it throws, it’s
ignored — the SDK never fails because of the context.
Your tags never clash with the context’s
If both bring the same key with a different value, yours wins and the
context’s is kept with the _ctx suffix. Nothing is silently overwritten.
| Case | Result |
|---|---|
| Only the context has it | the context’s stays |
| Only you write it | yours stays |
| Both, same value | they merge into one |
| Both, different values | yours wins, the other stays as <key>_ctx |
Tags (attrs)
attrs are key/value pairs you’ll search by later.
They can be text, a number or a boolean — and if you send something else, the SDK
doesn’t drop the event: it degrades it gracefully and marks it.
| You send it | It stores |
|---|---|
Error | "TypeError: x is not a function" + the _coerced mark |
Date | ISO 8601 |
| object / array | JSON truncated to 500 characters + _coerced |
| circular object | "[no serializable]" |
NaN / Infinity | its text + _coerced |
null / undefined | omitted (no noise) |
The _coerced attribute lists which keys were degraded. The idea is that
dirty data is visible and fixable, not invisible. If you have a large object
you do want to keep whole, it goes in the third parameter
(body), which is stored as full JSON.
Where it happened: geo
geo is not an attr: it’s part of the envelope, like
entity or trace_id. It can go in the event or in the
context, and both ways end up the same.
estiaje.event('order.created', { geo: { lat: 25.6866, lon: -100.3161 }, // acc optional, in meters slots: 8, });
| Case | Result |
|---|---|
| In the event and in the context | the event’s wins |
lat/lon out of range, or garbage | not uploaded, kept as geo_invalido |
0,0 | discarded: it’s a GPS without a fix, not the Gulf of Guinea |
No geo | the event still exists, it just doesn’t show on the map |
Only what happens in a real place needs it: a visit, a sale, a task closed on site. What happens inside the server doesn’t. The territory never makes up a position: if an event doesn’t bring one, it says so.
Destinations (sinks)
This is the core idea of the SDK: the declaration is stable and lasts for years; the destination is configuration. Send to Estiaje, to the console, to any OTLP backend — Sentry, Datadog, Grafana — or to all four at once.
import { createEstiaje, sinkConsole, sinkOtlp } from '@estiaje/sdk'; createEstiaje({ service: 'mi-api', url: process.env.ESTIAJE_URL || 'https://estiaje.mx', token: process.env.ESTIAJE_TOKEN, sinks: [ 'estiaje', sinkConsole(), sinkOtlp('https://…ingest.sentry.io/api/…/otlp/v1/logs'), { name: 'mío', send: (evs) => miLogger.info(evs) }, ], })
A sink of your own only needs two things:
interface EstiajeSink { name: string; send(events: EstiajeEvent[]): void | Promise<void>; immediate?: boolean; // true = don't wait for the batch (that's how the console works) }
Environment variables
| Variable | What it does |
|---|---|
ESTIAJE_TOKEN | Your ck_live_… key. It’s the only one you need. Without it nothing goes out over the network. |
ESTIAJE_URL | Optional. Another destination (local development, your own server). Without it: https://estiaje.mx |
ESTIAJE_SINKS | estiaje,console,otlp — comma-separated |
OTLP_ENDPOINT | The provider’s OTLP endpoint |
OTLP_HEADERS | Extra headers, k=v,k=v |
ESTIAJE_ECHO | 0 turns off the console copy (on by default) |
ESTIAJE_VERSION | Optional. The version announced at startup (see Deploys) |
ESTIAJE_REPO_URL | Optional. Your repository, to link each version to its commit (see Deploys) |
ESTIAJE_DESPLIEGUES | 0 turns off announcing the version at startup |
The console copy is on on purpose: you see it in development, and
AWS CloudWatch captures it without any setup. Turn it off with
ESTIAJE_ECHO=0 or echo: false.
Expectations
An error gets seen. A process that stopped halfway makes no noise. Expectations are how you find out about the silence without writing a single extra line of code.
They’re declared where the operation lives, not in the code:
{
"id": "pedido-completo",
"name": "Order created → order completed",
"when": "order.created",
"expects": "order.completed",
"fails": ["sales.rejected", "etl.failed"],
"match_by": "attrs.order_id",
"window_min": 30,
"severity": "error"
}
Each opener ends in one of three endings, and only one of them is announced:
| Ending | What happens |
|---|---|
Met — what’s in expects arrived | it closes silently |
Declared failure — something in fails arrived | it closes and is counted separately: your code detected the problem and said so, and that’s worth gold |
| Not closed — nothing arrived | it’s announced as expectation.unmet |
And if your code detects that the process was cut short, say so:
estiaje.outcome('task.canceled', { task_id }). A bad ending that’s
declared is information; what’s useless is silence.
What each process is worth
“3 not closed” is a number. “$1,800 at risk” is a finding. Each expectation can say what each process it watches is worth, in Mexican pesos, in one of three ways (in the dashboard: ¿Cuánto vale cada uno?, “How much is each one worth?”):
// don't know / doesn't apply "valor": null // each order is worth $600 "valor": { "tipo": "fijo", "monto": 600 } // the opening event says it (attrs.monto); if it doesn't have it, $600 "valor": { "tipo": "atributo", "atributo": "monto", "respaldo": 600 }
The amount is read from the event that opens (the one in when).
Numbers work, and so does text like "1,234.50", "$600" or
"700 MXN" (the comma is the thousands separator). A negative number or
something that isn’t an amount counts as “doesn’t have it” and is worth the fallback
(respaldo). You don’t need to touch the code or emit anything new: if your
event already carries monto, total or amount,
the dashboard suggests it.
| Total | What it is |
|---|---|
| At risk | what went past its deadline and still has no closing at all, from the last 7 days. The money you can still go and rescue. |
| Overdue, not closed | everything that went past its deadline in the period, even if it closed late afterwards: the sum of the “not closed” list. |
| Closed on time | what got its happy closing within the deadline. |
It’s calculated when you query, with the current value: if you set a value today, you see
in pesos what was already open. And the phone alert says it:
«$1,800 en riesgo: Pedidos surtidos» (“$1,800 at risk: Orders fulfilled”), with the name you gave the process
(without a value: «3 pedidos sin cierre: Pedidos surtidos», “3 orders not closed: Orders fulfilled”).
The dashboard and the alerts are in Spanish for now.
Through the API: valor when declaring or in a PATCH, and
GET /v1/orgs/:org/valor/resumen?desde=&hasta= for the
organization’s summary.
Rescue one
Finding the stuck order is half the job; the other half is opening it in your system. Each expectation can carry the link to your system, once, with a slot where each process’s key goes (in the dashboard: Para rescatar uno, abrir, “To rescue one, open”):
"liga_rescate": "https://admin.your-store.mx/orders/{order_id}"
Every row of “what was left not closed” that is still open then carries
Rescatar (Rescue), which opens that order. The slot is named after the key the
process is matched by ({order_id} for attrs.order_id,
{entity} for entity) or, always, {clave}.
The link starts with https://, the slot can’t go in the site’s
name and the key is encoded: an order 48/9 opens
…/orders/48%2F9. Through the API: liga_rescate when declaring or
in a PATCH (null removes it), and every row of
GET /v1/expectations/:id/unmet comes with its liga (link) already filled in.
Deploys
When something breaks, the first question is “what changed?”. Estiaje knows when you deployed, what and who, through two paths that lead to the same place.
On its own, at startup
Since version 0.5.0 of the SDK, when your process starts it looks at the version the platform left in the environment and announces it once. You don’t write anything. If the version didn’t change it’s a restart and doesn’t count; ten replicas starting with the same version are a single deploy.
| The version comes from (the first one present) | Where |
|---|---|
ESTIAJE_VERSION | yours: it beats everything |
VERCEL_GIT_COMMIT_SHA | Vercel, with who, title and branch |
RAILWAY_GIT_COMMIT_SHA | Railway, with who, title and branch |
RENDER_GIT_COMMIT | Render, with the branch |
HEROKU_SLUG_COMMIT, SOURCE_VERSION | Heroku |
K_REVISION | Cloud Run |
AWS_LAMBDA_FUNCTION_VERSION | Lambda (except $LATEST) |
FLY_IMAGE_REF | Fly.io |
npm_package_version | your package.json, if you started with npm start |
With none of them nothing is sent; nor without ESTIAJE_TOKEN, nor with
NODE_ENV=test. GITHUB_SHA doesn’t count here: GitHub Actions is
the CI, not where your service runs (that’s what the step below is for). Turn it off with despliegues: false or
ESTIAJE_DESPLIEGUES=0.
With who and what, from CI
One line at the end of the pipeline (desplegado means “deployed”):
npx @estiaje/sdk desplegado
It reads ESTIAJE_TOKEN and works out from the CI who deployed, the
commit title, the branch and the sha: GitHub Actions, GitLab, Bitbucket, CircleCI and Vercel,
and otherwise, git. In GitHub Actions:
- name: Notify Estiaje run: npx -y @estiaje/sdk desplegado --servicio mi-api env: ESTIAJE_TOKEN: ${{ secrets.ESTIAJE_TOKEN }} # and if something failed earlier, let that be known too - name: Notify Estiaje that it failed if: failure() run: npx -y @estiaje/sdk desplegado --servicio mi-api --fallo env: ESTIAJE_TOKEN: ${{ secrets.ESTIAJE_TOKEN }}
In GitLab CI, with ESTIAJE_TOKEN as a masked project variable:
avisar-estiaje: stage: .post script: npx -y @estiaje/sdk desplegado --servicio mi-api avisar-estiaje-fallo: stage: .post when: on_failure script: npx -y @estiaje/sdk desplegado --servicio mi-api --fallo
The commit link. Since 0.8.0, each deploy carries your repository's address
(from the CI, from Vercel or Railway, or git's origin;
ESTIAJE_REPO_URL wins). If the version is a sha, the dashboard links it to
its commit on GitHub, GitLab or Bitbucket. No integrations or permissions: it's just the
address, and it never travels with a user or password.
| Flag | What it does |
|---|---|
--servicio <nombre> | The service name, the same as in estiajeFromEnv({ service }). Without it: the name in package.json, or the repo |
--version <v> | Without it: the commit sha |
--titulo, --quien | Title and who, if you don’t want what the CI says |
--fallo | The deploy failed (for the step with if: failure()) |
--silencioso | Prints nothing if everything goes well |
Your status on your site
Your status page (estiaje.mx/estado/your-business, from the Inicio
plan up) can also live inside your own site, on the Pro and Max plans:
embedded as is, or
by reading its data to render it with your own design. Both depend on the
same list: the sites allowed to show it.
Publish the page in the dashboard, under Salud → Página de estado (Health → Status page).
Choose which services appear in Servicios en la página (Services on the page). They appear with their name; never addresses or errors.
Add your sites in Ponerla en tu sitio (Put it on your site): type
your-business.mx and it’s saved as https://your-business.mx.
Only https, no IPs or wildcards, up to 10. If you have
www. and no www., add both.
Embedded
Paste this wherever you want to see it (the dashboard gives it to you with your address already filled in):
<iframe src="https://estiaje.mx/incrustar/your-business" title="Your Business status" style="width:100%;height:520px;border:0" loading="lazy"></iframe> <script>addEventListener('message',e=>{if(e.origin==='https://estiaje.mx'&&e.data&&e.data.estiaje==='alto') document.querySelector('iframe[src^="https://estiaje.mx/incrustar/"]').style.height=e.data.alto+'px'})</script>
It shows up without the full page’s header, with a transparent background and a discreet
“Estado por Estiaje” that opens the whole page in another tab. The
<script> is optional: the page reports its height and the iframe
adjusts on its own, with no scroll bar. It’s light; if your site is dark, add
?tema=oscuro (dark) to the address (or ?tema=auto to follow
the viewer’s phone). It can only be embedded in the sites on your list; on
any other, the browser won’t show it.
The data, with your design
The same information the page uses, as JSON, with no key:
GET https://estiaje.mx/v1/estado/your-business
{
"titulo": "Your Business",
"estado_general": "operando", // operational; or "falla_parcial" (partial outage), "caida" (down)
"actualizado": "2026-10-04T18:42:10.000Z",
"servicios": [{
"nombre": "Online store",
"estado": "arriba", // up; or "caida" (down), "pausado" (paused)
"uptime_90d": 99.97, // null if there's no data yet
"dias": [{ "dia": "2026-07-07", "uptime": 100, "estado": "ok" }, /* … 90 days, oldest first */]
}],
"incidentes": [{
"servicio": "Online store",
"inicio": "2026-09-28T03:12:00.000Z",
"fin": "2026-09-28T03:19:00.000Z", // null = still down
"duracion_min": 7
}]
}
Each day comes with estado: "ok", "falla" (brief
failures), "caida" (down) or "sin_datos" (no data). If the page
isn’t published it responds 404.
From the browser, on a page of one of your sites (on any other site the browser won’t let it be read):
const r = await fetch('https://estiaje.mx/v1/estado/your-business'); const estado = await r.json(); document.querySelector('#estado').textContent = estado.estado_general === 'operando' ? 'All systems working' : 'We have issues';
From your server (Node 18 or later) it works regardless of the list. Cache it for 30 seconds: the response doesn’t change more often than that.
let guardado = null, cuando = 0; export async function estadoDeMiNegocio() { if (guardado && Date.now() - cuando < 30_000) return guardado; const r = await fetch('https://estiaje.mx/v1/estado/your-business', { signal: AbortSignal.timeout(3000) }); if (!r.ok) return guardado; // the previous one, if there was one guardado = await r.json(); cuando = Date.now(); return guardado; }
429 with Retry-After. From your customers’ browsers
you won’t notice (each one counts separately); from your server, the 30-second cache
leaves you plenty of room.
Retention and quotas
log entries are noise and get deleted according to your plan.
event and outcome are kept, because they’re the ones that
answer business questions months later. On top of that there’s a
per-minute rollup that survives deletion: each service’s volume and health
are still there even when the detail is gone.
The quota is charged before reading the request body — a huge batch
costs you no CPU if you’re already over. If you exceed it, the gate responds
429 with Retry-After, and the SDK retries on its own.
HTTP API
The SDK is a convenience, not a requirement. Everything can be done with
curl. The credential goes in Authorization: Bearer, and
the organization always comes from the credential — never from the payload or
a parameter.
Send events
curl -X POST https://estiaje.mx/v1/events \ -H 'content-type: application/json' \ -H 'authorization: Bearer ck_live_…' \ -d '[{"service":"mi-api","kind":"event","name":"order.created", "attrs":{"order_id":"AND-8821"}}]'
It accepts an object or an array. It responds 202 with
{accepted, deduped, rejected[]}: one bad event doesn’t take down the
batch — only that one is rejected, with its index and its reason.
From your language
The official SDK is for Node/TypeScript. For everything else you don’t need to wait for one:
it’s this same POST, sent without waiting for the response and with a
short timeout so Estiaje never slows your application down. The recipes for Python,
PHP, Java, C#, Go and Ruby, with their middleware to follow the thread across services,
are in Does it work with your stack?.
Query
| Endpoint | What for |
|---|---|
GET /v1/events | Search. Filter by q (name prefix), service, kind, severity, entity, trace, attr=clave:valor (key:value), con_geo=1. Relative ranges: desde=-24h. Cursor-paginated. |
GET /v1/traces/:trace | A journey read as a story: the steps in order, how long it took between one and the next, and which was the longest gap. |
GET /v1/services | Who’s alive: volume, mix, errors and silence per service. |
GET /v1/latency | p50/p95/max of attrs.ms grouped by any tag or column. name=* = everything that carries ms. |
GET /v1/expectations | What’s being watched and how each one is doing, with its value and what’s at risk in pesos. |
GET /v1/live | SSE: the live river, one summary per second. |
Two kinds of credential
| Kind | Who | Can |
|---|---|---|
ck_live_… | a service | emit and query. Stored encrypted and shown only once. |
cs_… | a person | sign in to the dashboard. Lasts 30 days. Can’t emit. |
What NOT to do
- Don’t create a client per request or per file. One per process.
- Don’t wrap
estiaje.*in try/catch. The SDK never throws. - Don’t
awaitlog/event/outcome: they’re synchronous and fire-and-forget. The only thing you await isflush(), when shutting down the process and at the end of a lambda. - Don’t write
spacein every event: it comes from the credential. - Don’t invent deadlines or “open/close flows” in the code. Deadlines are defined by the operation, in the expectations.
log for what
happened, event for what matters to the business,
outcome for what ends a process. In business events include
the identifier of the thing. And if your code detects that something was cut short,
say so. That’s all.