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.

StackHowEvents and watched processesDeploysThe journey across servicesTested
Node.js · Express, NestJS, Fastify, Next.jsOfficial SDKYesOn its own at startup, or from CIYes, with the contextTested in CI
Bun · DenoOfficial SDKYesFrom CIYes, with the contextNot tested yet
Python · Django, Flask, FastAPIHTTP recipeYesFrom CIAutomatic with the middlewareTested in CI
PHP · Laravel, WordPressHTTP recipeYesFrom CIAutomatic with the middlewareTested in CI
Java · Spring BootHTTP recipeYesFrom CIAutomatic with the filterTested in CI
C# · ASP.NET CoreHTTP recipeYesFrom CIAutomatic with the middlewareTested in CI
Go · net/http, GinHTTP recipeYesFrom CIAutomatic with the middlewareTested in CI
Ruby · Rails, SinatraHTTP recipeYesFrom CIAutomatic with the middlewareTested in CI
Serverless · Vercel, AWS Lambda, Cloudflare WorkersSDK or HTTP, waiting at the endYesFrom CIPassing traceparentNot tested yet
No-code · n8n, Make, ZapierHTTP requestYes——Not tested yet
CI · GitHub Actions, GitLab, Bitbucket, CircleCI, Vercelnpx @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

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.esperar in Ruby, .join() in Java and await in C#.
  • Cloudflare Workers: ctx.waitUntil lets 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.

FieldValue
MethodPOST
URLhttps://estiaje.mx/v1/events
Headersauthorization: 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

1

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.

2

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.

3

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.

VerbWhat forLives
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.

If you only use 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() }));

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.

The 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.

CaseResult
Only the context has itthe context’s stays
Only you write ityours stays
Both, same valuethey merge into one
Both, different valuesyours 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 itIt stores
Error"TypeError: x is not a function" + the _coerced mark
DateISO 8601
object / arrayJSON truncated to 500 characters + _coerced
circular object"[no serializable]"
NaN / Infinityits text + _coerced
null / undefinedomitted (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,
});
CaseResult
In the event and in the contextthe event’s wins
lat/lon out of range, or garbagenot uploaded, kept as geo_invalido
0,0discarded: it’s a GPS without a fix, not the Gulf of Guinea
No geothe 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) },
  ],
})
Each destination has its own queue. If Sentry is down, Estiaje keeps receiving — and the other way around. A slow provider doesn’t infect the others, and the one that comes back catches up on its own. A sink that throws is isolated: it never takes down the emitter.

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

VariableWhat it does
ESTIAJE_TOKENYour ck_live_… key. It’s the only one you need. Without it nothing goes out over the network.
ESTIAJE_URLOptional. Another destination (local development, your own server). Without it: https://estiaje.mx
ESTIAJE_SINKSestiaje,console,otlp — comma-separated
OTLP_ENDPOINTThe provider’s OTLP endpoint
OTLP_HEADERSExtra headers, k=v,k=v
ESTIAJE_ECHO0 turns off the console copy (on by default)
ESTIAJE_VERSIONOptional. The version announced at startup (see Deploys)
ESTIAJE_REPO_URLOptional. Your repository, to link each version to its commit (see Deploys)
ESTIAJE_DESPLIEGUES0 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:

EndingWhat happens
Met — what’s in expects arrivedit closes silently
Declared failure — something in fails arrivedit closes and is counted separately: your code detected the problem and said so, and that’s worth gold
Not closed — nothing arrivedit’s announced as expectation.unmet
There are no timers or flows to register. It’s a query that runs periodically and compares openings against closings. It’s idempotent by derivation: the same opener is never announced twice, even if the reconciliation runs a thousand times.

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.

TotalWhat it is
At riskwhat 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 closedeverything that went past its deadline in the period, even if it closed late afterwards: the sum of the “not closed” list.
Closed on timewhat 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_VERSIONyours: it beats everything
VERCEL_GIT_COMMIT_SHAVercel, with who, title and branch
RAILWAY_GIT_COMMIT_SHARailway, with who, title and branch
RENDER_GIT_COMMITRender, with the branch
HEROKU_SLUG_COMMIT, SOURCE_VERSIONHeroku
K_REVISIONCloud Run
AWS_LAMBDA_FUNCTION_VERSIONLambda (except $LATEST)
FLY_IMAGE_REFFly.io
npm_package_versionyour 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.

FlagWhat 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, --quienTitle and who, if you don’t want what the CI says
--falloThe deploy failed (for the step with if: failure())
--silenciosoPrints nothing if everything goes well
It never breaks your deploy. It always exits with 0: with Estiaje down, no network or no key, it warns in one line and the pipeline carries on. It never prints the key. Use the same service name in the SDK and in the CI: that way the CI notice and that version’s startup count as a single deploy.

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.

1

Publish the page in the dashboard, under Salud → Página de estado (Health → Status page).

2

Choose which services appear in Servicios en la página (Services on the page). They appear with their name; never addresses or errors.

3

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;
}
Up to 120 requests per minute from the same IP; beyond that, it responds 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

EndpointWhat for
GET /v1/eventsSearch. 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/:traceA 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/servicesWho’s alive: volume, mix, errors and silence per service.
GET /v1/latencyp50/p95/max of attrs.ms grouped by any tag or column. name=* = everything that carries ms.
GET /v1/expectationsWhat’s being watched and how each one is doing, with its value and what’s at risk in pesos.
GET /v1/liveSSE: the live river, one summary per second.

Two kinds of credential

KindWhoCan
ck_live_…a serviceemit and query. Stored encrypted and shown only once.
cs_…a personsign in to the dashboard. Lasts 30 days. Can’t emit.

What NOT to do

The thirty-second version. Import and emit: 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.

Terms (es) · Privacy notice (es) · hola@estiaje.mx