Documentación de Estiaje

Estiaje recibe lo que pasó en tu código y lo que le importa a tu negocio en un mismo río. Esta página es todo lo que necesitas: se lee en quince minutos y no hay una segunda parte escondida.

Instalar

npm install @estiaje/sdk

Cero dependencias. Node 18 o superior. Funciona igual en una API, en un front empaquetado y en una lambda. ¿Tu servicio no es de Node? Mira ¿Funciona con tu stack?, justo abajo.

¿Funciona con tu stack?

Con Node usas el SDK oficial. Con cualquier otro lenguaje es el mismo POST a /v1/events, y abajo está la receta de cada uno, lista para copiar: dónde va el código, cómo no frenar tu aplicación y cómo seguir el hilo de un pedido de un servicio a otro.

StackCómoEventos y procesos vigiladosDesplieguesEl viaje entre serviciosProbado
Node.js · Express, NestJS, Fastify, Next.jsSDK oficialSíSolo al arrancar, o desde el CISí, con el contextoProbado en CI
Bun · DenoSDK oficialSíDesde el CISí, con el contextoPor probar
Python · Django, Flask, FastAPIReceta HTTPSíDesde el CIAutomático con el middlewareProbado en CI
PHP · Laravel, WordPressReceta HTTPSíDesde el CIAutomático con el middlewareProbado en CI
Java · Spring BootReceta HTTPSíDesde el CIAutomático con el filtroProbado en CI
C# · ASP.NET CoreReceta HTTPSíDesde el CIAutomático con el middlewareProbado en CI
Go · net/http, GinReceta HTTPSíDesde el CIAutomático con el middlewareProbado en CI
Ruby · Rails, SinatraReceta HTTPSíDesde el CIAutomático con el middlewareProbado en CI
Serverless · Vercel, AWS Lambda, Cloudflare WorkersSDK o HTTP, esperando al finalSíDesde el CIPasando traceparentPor probar
Sin código · n8n, Make, ZapierPetición HTTPSí——Por probar
CI · GitHub Actions, GitLab, Bitbucket, CircleCI, Vercelnpx @estiaje/sdk desplegado—Sí, con quién y qué—Probado en CI

Probado en CI: una prueba automática corre la receta, tal cual está en esta página, contra Estiaje en cada cambio: el evento llega, y el hilo entra y sale con el mismo trace_id. Las líneas propias de cada framework (registrar el middleware) no se corren. Por probar: debería funcionar, pero todavía no lo comprobamos.

Lo que hace cada receta

Node.js · Express, NestJS, Fastify, Next.js

El SDK oficial: Instalar, tu primer evento y, para el hilo entre servicios, Contexto y linaje.

Python · Django, Flask, FastAPI

Copia estiaje.py a tu proyecto. Python 3.8 o superior.

# estiaje.py: copia este archivo a tu proyecto. Sin dependencias.
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):
    """Al entrar una petición: toma el hilo de la cabecera o crea uno nuevo."""
    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():
    """Para las llamadas que salen a otro servicio: el mismo hilo, un paso nuevo."""
    return "00-%s-%s-01" % (_hilo.get() or secrets.token_hex(16), secrets.token_hex(8))


def evento(name, attrs=None, kind="event"):
    """Manda el evento en segundo plano: nunca frena ni rompe tu app."""
    if not os.environ.get("ESTIAJE_TOKEN"):
        return  # sin llave no sale nada por la red
    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():
    """En una lambda, antes de devolver: que salga lo pendiente (2 s como máximo)."""
    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  # si Estiaje no contesta, tu app sigue

Donde pase algo:

import estiaje

estiaje.evento("pedido.creado", {"order_id": pedido.id, "monto": pedido.total})

Django: el hilo, en un middleware.

# miapp/middleware.py  (y en 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: el hilo en un middleware, y el evento con BackgroundTasks para que salga después de responder.

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})

Al llamar a otro de tus servicios, pasa el hilo:

requests.get(url, headers={"traceparent": estiaje.traceparent()}, timeout=5)

En un script o un cron no hace falta nada más: Python espera el envío antes de salir.

PHP · Laravel, WordPress

Copia estiaje.php a tu proyecto. PHP 7.4 o superior, con la extensión curl. Los eventos se juntan y se mandan al final, cuando tu usuario ya tiene su respuesta (con PHP-FPM).

<?php
// estiaje.php: cópialo a tu proyecto (en Laravel, app/Estiaje.php). Sin dependencias.
final class Estiaje
{
    const SERVICIO = 'mi-api';
    private static ?string $hilo = null;
    private static array $lote = [];

    /** Al entrar una petición: toma el hilo de la cabecera o crea uno nuevo. */
    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;
    }

    /** Para las llamadas que salen a otro servicio: el mismo hilo, un paso nuevo. */
    public static function traceparent(): string
    {
        return '00-' . (self::$hilo ?? bin2hex(random_bytes(16))) . '-' . bin2hex(random_bytes(8)) . '-01';
    }

    /** Junta el evento; se manda al final, ya que tu usuario tiene su respuesta. */
    public static function evento(string $name, array $attrs = [], string $kind = 'event'): void
    {
        if (!getenv('ESTIAJE_TOKEN')) return; // sin llave no sale nada por la red
        $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); // si Estiaje no contesta, tu app sigue
    }
}

Donde pase algo:

Estiaje::evento('pedido.creado', ['order_id' => $pedido->id, 'monto' => $pedido->total]);

Laravel: el hilo, en un 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+; antes, en $middleware de app/Http/Kernel.php)
->withMiddleware(fn ($middleware) => $middleware->append(\App\Http\Middleware\HiloEstiaje::class))

Al llamar a otro de tus servicios con el cliente de Laravel, pasa el hilo:

Http::withHeaders(['traceparent' => \Estiaje::traceparent()])->get($url);

¿Prefieres mandar el evento con el cliente de Laravel? Que salga después de responder:

// en 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 lee la llave con getenv. Con php artisan config:cache, Laravel ya no carga el .env: pon ESTIAJE_TOKEN como variable del servidor.

WordPress, en tu plugin o en functions.php:

require_once __DIR__ . '/estiaje.php';
add_action('init', fn () => Estiaje::empezarHilo($_SERVER['HTTP_TRACEPARENT'] ?? null));

// al llamar a otro servicio
wp_remote_get($url, ['headers' => ['traceparent' => Estiaje::traceparent()]]);
Java · Spring Boot

Copia Estiaje.java a tu proyecto. Java 17 o superior.

// Estiaje.java: cópialo a tu proyecto (con tu package). Sin dependencias, 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();

    /** Al entrar una petición: toma el hilo de la cabecera o crea uno nuevo. */
    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;
    }

    /** Al terminar la petición: el hilo de Java se reusa para la siguiente. */
    public static void terminarHilo() {
        HILO.remove();
    }

    /** Para las llamadas que salen a otro servicio: el mismo hilo, un paso nuevo. */
    public static String traceparent() {
        String hilo = HILO.get();
        return "00-" + (hilo != null ? hilo : hex(16)) + "-" + hex(8) + "-01";
    }

    /** Manda el evento en segundo plano: nunca frena ni rompe tu 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); // si algo falla, tu app sigue
        }
    }

    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();
    }
}

Donde pase algo:

Estiaje.evento("pedido.creado", Map.of("order_id", pedido.getId(), "monto", pedido.getTotal()));

Spring Boot: el hilo, en un filtro.

@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();
        }
    }
}

Al llamar a otro de tus servicios (con RestClient), pasa el hilo:

RestClient http = RestClient.builder()
    .requestInterceptor((req, cuerpo, sigue) -> {
        req.getHeaders().set("traceparent", Estiaje.traceparent());
        return sigue.execute(req, cuerpo);
    })
    .build();

En un programa que termina enseguida, espera el envío: Estiaje.evento(…).join().

C# · ASP.NET Core

Copia Estiaje.cs a tu proyecto. .NET 6 o superior.

// Estiaje.cs: cópialo a tu proyecto. Sin paquetes, .NET 6 o superior.
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}$");

    /// Al entrar una petición: toma el hilo de la cabecera o crea uno nuevo.
    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;
    }

    /// Para las llamadas que salen a otro servicio: el mismo hilo, un paso nuevo.
    public static string Traceparent() => $"00-{Hilo.Value ?? Azar(16)}-{Azar(8)}-01";

    /// Manda el evento en segundo plano: nunca frena ni rompe tu app. No hace falta esperarlo.
    public static Task Evento(string name, object? attrs = null)
    {
        var token = Environment.GetEnvironmentVariable("ESTIAJE_TOKEN");
        if (string.IsNullOrEmpty(token)) return Task.CompletedTask; // sin llave no sale nada
        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 => // si Estiaje no contesta, tu app sigue
        {
            if (t.IsCompletedSuccessfully) t.Result.Dispose(); else _ = t.Exception;
        });
    }

    static string Azar(int bytes) => Convert.ToHexString(RandomNumberGenerator.GetBytes(bytes)).ToLowerInvariant();
}

/// Pone traceparent en lo que sale por un HttpClient hacia tus otros servicios.
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);
    }
}

En Program.cs: el hilo en un middleware, y que tus HttpClient lo pasen a tus otros servicios.

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();
});

Donde pase algo (no hace falta await):

Estiaje.Evento("pedido.creado", new { order_id = pedido.Id, monto = pedido.Total });

En una app de consola que termina enseguida, sí espéralo: await Estiaje.Evento(…).

Go · net/http, Gin

Copia estiaje.go a tu proyecto, en su paquete estiaje. Go 1.18 o superior.

// estiaje.go: cópialo a tu proyecto (paquete estiaje). Sin dependencias.
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: al entrar una petición, toma el hilo de la cabecera o crea uno nuevo.
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 para net/http (sirve igual con chi o 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: para las llamadas que salen a otro servicio. El mismo hilo, un paso nuevo.
func Traceparent(ctx context.Context) string {
	h := Hilo(ctx)
	if h == "" {
		h = azar(16)
	}
	return "00-" + h + "-" + azar(8) + "-01"
}

// Transporte pone traceparent en todo lo que sale por un http.Client:
// &http.Client{Transport: estiaje.Transporte{}}, y la petición con su 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 lo manda en segundo plano: nunca frena ni rompe tu app.
func Evento(ctx context.Context, name string, attrs map[string]any) {
	token := os.Getenv("ESTIAJE_TOKEN")
	if token == "" {
		return // sin llave no sale nada por la red
	}
	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: en un programa que termina enseguida (un cron), llámalo antes de salir.
func Esperar() { pendientes.Wait() }

func azar(n int) string {
	b := make([]byte, n)
	rand.Read(b)
	return hex.EncodeToString(b)
}

net/http (y chi, gorilla/mux): envuelve tu router, y usa el Transporte para lo que sale.

http.ListenAndServe(":8080", estiaje.Middleware(mux))

// donde pase algo, con el contexto de la petición
estiaje.Evento(r.Context(), "pedido.creado", map[string]any{"order_id": p.ID, "monto": p.Total})

// al llamar a otro de tus servicios
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()
})

En un programa que termina enseguida (un cron), llama estiaje.Esperar() antes de salir.

Ruby · Rails, Sinatra

Copia estiaje.rb a tu proyecto. Ruby 2.7 o superior, sin gemas.

# estiaje.rb: cópialo a tu proyecto (en Rails, lib/estiaje.rb). Sin gemas.
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

  # Al entrar una petición: toma el hilo de la cabecera o crea uno nuevo.
  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

  # Para las llamadas que salen a otro servicio: el mismo hilo, un paso nuevo.
  def self.traceparent
    "00-#{hilo || SecureRandom.hex(16)}-#{SecureRandom.hex(8)}-01"
  end

  # Manda el evento en segundo plano: nunca frena ni rompe tu app.
  def self.evento(name, attrs = {}, kind: "event")
    token = ENV["ESTIAJE_TOKEN"].to_s
    return if token.empty? # sin llave no sale nada por la red
    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 # si Estiaje no contesta, tu app sigue
    end
    @candado.synchronize { @envios.select!(&:alive?); @envios << envio }
  end

  # En un script que termina enseguida (un cron, un rake), llámalo antes de salir.
  def self.esperar
    @candado.synchronize { @envios.dup }.each(&:join)
  end

  # El middleware de Rack: 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, en config/application.rb (en Sinatra: use Estiaje::Middleware):

require_relative "../lib/estiaje"
# …dentro de class Application:
config.middleware.use Estiaje::Middleware

Donde pase algo, y al llamar a otro de tus servicios:

Estiaje.evento("pedido.creado", { order_id: pedido.id, monto: pedido.total })

req = Net::HTTP::Get.new(uri)
req["traceparent"] = Estiaje.traceparent

En un script o una tarea de rake, llama Estiaje.esperar antes de salir.

Serverless · Vercel, AWS Lambda, Cloudflare Workers

Una función serverless se congela en cuanto devuelve su respuesta, y lo que estaba por salir se queda sin salir. Por eso, aquí sí se espera el envío al final.

  • Vercel y Lambda con Node: await estiaje.flush() antes de devolver la respuesta.
  • Lambda con las recetas de arriba: estiaje.esperar() en Python, estiaje.Esperar() en Go, Estiaje.esperar en Ruby, .join() en Java y await en C#.
  • Cloudflare Workers: ctx.waitUntil deja que el envío termine después de responder, sin hacer esperar a tu usuario.
export default {
  async fetch(request, env, ctx) {
    // el hilo: el de la cabecera traceparent, o uno nuevo
    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(() => {}));  // si Estiaje no contesta, tu worker sigue

    return new Response('ok');
  },
};
Sin código · n8n, Make, Zapier

Un paso de petición HTTP al final de tu flujo: en n8n, el nodo HTTP Request; en Make, HTTP › Make a request; en Zapier, Webhooks by Zapier › Custom Request.

CampoValor
MétodoPOST
URLhttps://estiaje.mx/v1/events
Cabecerasauthorization: Bearer ck_live_… y content-type: application/json
Cuerpo (JSON)el de abajo (así se escribe en n8n), con los datos del paso anterior
[{"service": "n8n", "kind": "event", "name": "pedido.creado",
  "attrs": {"order_id": "{{ $json.id }}", "monto": {{ $json.total }}}}]

La llave guárdala en las credenciales de la herramienta, no escrita en el paso. Marca el paso para que siga aunque falle: si Estiaje no contesta, tu flujo no se detiene.

CI · GitHub Actions, GitLab, Bitbucket, CircleCI, Vercel

Una línea al final de tu pipeline avisa de cada despliegue, con quién y qué: npx @estiaje/sdk desplegado. Funciona sin importar en qué lenguaje esté tu servicio. Los detalles, en Despliegues.

¿Quieres un SDK para tu lenguaje, con cola en memoria y reintentos como el de Node? Escríbenos a hola@estiaje.mx: el primero que lo pida decide cuál sigue.

Tu primer evento

1

Consigue tu llave

Crea tu organización en la pantalla de alta. Te devuelve una llave ck_live_… que se muestra una sola vez: se guarda cifrada, así que si la pierdes se revoca y se genera otra.

2

Apunta el SDK

ESTIAJE_TOKEN=ck_live_…

Una sola línea: con la llave, el SDK ya manda a https://estiaje.mx. Sin ESTIAJE_TOKEN no sale nada por la red — todas las funciones existen e imprimen a consola. Puedes instrumentar hoy y poner la llave después.

3

Emite

import { estiajeFromEnv } from '@estiaje/sdk';

// UNO por proceso. No uno por request, ni uno por archivo.
export const estiaje = estiajeFromEnv({ service: 'mi-api' });

estiaje.log('usecase.ok', { usecase: 'GetVisits', ms: 128 });

Ábrelo en el río y verás caer el evento en menos de dos segundos.

Los tres verbos

La diferencia no es de estilo: decide cuánto vive cada cosa y qué preguntas vas a poder contestar dentro de un año.

VerboPara quéVive
estiaje.log()Lo que pasó. Diagnóstico, tiempos, pasos internos.Se borra: es ruido con fecha de caducidad.
estiaje.event()Lo que le importa al negocio: una visita empezó, un planograma cambió.Se conserva.
estiaje.outcome()Lo que TERMINA un proceso: la venta se procesó, la tarea cerró.Se conserva. Es la desembocadura.

También hay warn(), error() y fatal(): son log con severidad.

Si solo usas log(), no te falta nada. Los otros dos verbos aparecen el día que quieras contestar "¿cuántos pedidos terminaron en cobro procesada?" sin abrir la base de datos.

Nombra por lo que ocurrió, no por dónde ocurrió

order.created, no PedidoController.done. El nombre sobrevive al refactor; la ruta del archivo, no. Y en los eventos de negocio incluye siempre el identificador de la cosa (task_id, order_id, sn): es lo que permite emparejar el inicio con el final.

Tus logs de siempre

Si tu app ya escribe con console.log, pino o winston, no cambies nada: una línea y esos logs llegan a Estiaje, donde los buscas por texto y los filtras por servicio, severidad o cualquier etiqueta. Vienen en todos los planes, desde Gratis. Desde el SDK 0.7.0.

// console.log, info, warn y error, tal como ya los escribes
estiaje.capturarConsola();
// pino: a la consola y a Estiaje
const logger = pino(pino.multistream([
  { stream: process.stdout },
  { stream: estiaje.destino() },
]));

// winston: un transporte más
logger.add(new winston.transports.Stream({ stream: estiaje.destino() }));

Los logs se guardan 3 días en Gratis, 7 en Inicio, 30 en Pro y 90 en Max. Los errores, más: 14 días, 90 días o un año (ver Retención y cuotas). Desde otro lenguaje, manda kind: "log" por la API HTTP.

Contexto y linaje

El hilo, la entidad, el actor y las etiquetas del request no se escriben en cada llamada. Se declaran una vez y se pegan solos a todo lo que se emita.

estiajeFromEnv({
  service: 'api-pedidos',
  context: () => ({
    trace_id: hiloActual(),        // del interceptor / AsyncLocalStorage
    entity:   'sucursal:polanco',     // el ancla: la cosa del mundo real
    actor:    usuarioActual(),
    attrs:    { session_id, module: 'checkout' },
    geo:      posicionActual(),
  }),
})

Si el front manda su cabecera traceparent y tu interceptor la respeta, la misma traza va del click del usuario hasta la lambda sin que ningún caso de uso escriba un trace_id jamás. Eso es lo que dibuja el viaje.

El hook context() se llama en cada emisión. Si lanza, se ignora — el SDK nunca falla por culpa del contexto.

Tus etiquetas nunca chocan con las del contexto

Si ambos traen la misma clave con distinto valor, gana la tuya y la del contexto se conserva con el sufijo _ctx. Nada se pisa en silencio.

CasoResultado
Solo el contexto la traequeda la del contexto
Solo tú la escribesqueda la tuya
Ambos, mismo valorse funden en una
Ambos, valores distintosmanda la tuya, la otra queda como <clave>_ctx

Etiquetas (attrs)

Los attrs son pares clave/valor con los que después vas a buscar. Pueden ser texto, número o booleano — y si mandas otra cosa, el SDK no tira el evento: lo degrada con gracia y lo marca.

Le mandasGuarda
Error"TypeError: x is not a function" + marca _coerced
DateISO 8601
objeto / arrayJSON truncado a 500 caracteres + _coerced
objeto circular"[no serializable]"
NaN / Infinitysu texto + _coerced
null / undefinedse omite (sin ruido)

El atributo _coerced lista qué claves se degradaron. La idea es que el dato sucio sea visible y corregible, no invisible. Si tienes un objeto grande que sí quieres conservar entero, va en el tercer parámetro (body), que se guarda como JSON completo.

Dónde pasó: geo

geo no es un attr: es parte del sobre, como entity o trace_id. Puede ir en el evento o en el contexto, y las dos formas terminan igual.

estiaje.event('order.created', {
  geo: { lat: 25.6866, lon: -100.3161 },  // acc opcional, en metros
  slots: 8,
});
CasoResultado
En el evento y en el contextomanda el del evento
lat/lon fuera de rango, o basurano sube, se conserva como geo_invalido
0,0se descarta: es un GPS sin fijar, no el Golfo de Guinea
Sin geoel evento existe igual, solo que no sale en el mapa

Solo lo que ocurre en un lugar real lo necesita: una visita, una venta, una tarea cerrada en sitio. Lo que pasa dentro del servidor, no. El territorio nunca inventa una posición: si un evento no la trae, lo dice.

Destinos (sinks)

Esta es la idea central del SDK: la declaración es estable y dura años; el destino es configuración. Manda a Estiaje, a la consola, a cualquier backend OTLP — Sentry, Datadog, Grafana — o a los cuatro a la vez.

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) },
  ],
})
Cada destino tiene su propia cola. Si Sentry está caído, Estiaje sigue recibiendo — y al revés. Un proveedor lento no contagia a los otros, y el que revive se pone al día solo. Un sink que lanza se aísla: jamás tumba al emisor.

Un sink tuyo solo necesita dos cosas:

interface EstiajeSink {
  name: string;
  send(events: EstiajeEvent[]): void | Promise<void>;
  immediate?: boolean;   // true = sin esperar al lote (así es la consola)
}

Variables de entorno

VariableQué hace
ESTIAJE_TOKENTu llave ck_live_…. Es la única que hace falta. Sin ella no sale nada por la red.
ESTIAJE_URLOpcional. Otro destino (desarrollo local, tu propio servidor). Sin ella: https://estiaje.mx
ESTIAJE_SINKSestiaje,console,otlp — separados por coma
OTLP_ENDPOINTEl endpoint OTLP del proveedor
OTLP_HEADERSCabeceras extra, k=v,k=v
ESTIAJE_ECHO0 apaga la copia a consola (viene encendida)
ESTIAJE_VERSIONOpcional. La versión que se anuncia al arrancar (ver Despliegues)
ESTIAJE_REPO_URLOpcional. Tu repositorio, para ligar cada versión a su commit (ver Despliegues)
ESTIAJE_DESPLIEGUES0 apaga el anuncio de la versión al arrancar

La copia a consola está encendida a propósito: se ve en desarrollo, y en AWS CloudWatch la captura sin configurar nada. Se apaga con ESTIAJE_ECHO=0 o echo: false.

Expectativas

Un error se ve. Un proceso que se quedó a la mitad no hace ruido. Las expectativas son la forma de enterarte del silencio sin escribir una línea de código extra.

Se declaran donde vive la operación, no en el código:

{
  "id":         "pedido-completo",
  "name":       "Pedido creado → pedido completado",
  "when":       "order.created",
  "expects":    "order.completed",
  "fails":      ["sales.rejected", "etl.failed"],
  "match_by":   "attrs.order_id",
  "window_min": 30,
  "severity":   "error"
}

Cada abridor termina en uno de tres finales, y solo uno se anuncia:

FinalQué pasa
Cumplida — llegó lo de expectsse cierra en silencio
Fallida declarada — llegó algo de failsse cierra y se cuenta aparte: tu código detectó el problema y lo dijo, eso vale oro
Sin cierre — no llegó nadase anuncia como expectation.unmet
No hay cronómetros ni flujos que registrar. Es una consulta que corre periódicamente y compara aperturas contra cierres. Es idempotente por derivación: el mismo abridor no se anuncia dos veces, aunque la reconciliación corra mil veces.

Y si tu código detecta que el proceso se truncó, dilo: estiaje.outcome('task.canceled', { task_id }). Un final malo declarado es información; lo que no sirve es el silencio.

Cuánto vale cada proceso

«3 sin cierre» es un número. «$1,800 en riesgo» es un hallazgo. Cada expectativa puede decir cuánto vale cada proceso que vigila, en pesos, de una de tres formas (en el panel: ¿Cuánto vale cada uno?):

// no sé / no aplica
"valor": null
// cada pedido vale $600
"valor": { "tipo": "fijo", "monto": 600 }
// lo dice el evento que abre (attrs.monto); si no lo trae, $600
"valor": { "tipo": "atributo", "atributo": "monto", "respaldo": 600 }

El monto se lee del evento que abre (el de when). Sirven números y textos como "1,234.50", "$600" o "700 MXN" (la coma es de miles). Un negativo o algo que no es un monto cuenta como «no lo trae» y vale el respaldo. No hay que tocar el código ni emitir nada nuevo: si tu evento ya lleva monto, total o amount, el panel lo propone.

CuentaQué es
En riesgolo que se pasó de su plazo y sigue sin ningún cierre, de los últimos 7 días. El dinero que todavía puedes ir a rescatar.
Vencido sin cierretodo lo que se pasó de su plazo en el periodo, aunque después haya cerrado tarde: la suma de la lista «sin cierre».
Cerrado a tiempolo que recibió su cierre feliz dentro del plazo.

Se calcula al consultar, con el valor vigente: si le pones valor hoy, ves en pesos lo que ya estaba abierto. Y el aviso al teléfono lo dice: «$1,800 en riesgo: Pedidos surtidos», con el nombre que le pusiste al proceso (sin valor: «3 pedidos sin cierre: Pedidos surtidos»). Por la API: valor al declarar o en un PATCH, y GET /v1/orgs/:org/valor/resumen?desde=&hasta= para el resumen de la organización.

Rescatar uno

Encontrar el pedido atorado es la mitad; la otra es abrirlo en tu sistema. Cada expectativa puede llevar la liga a tu sistema, una vez, con un hueco donde va la clave de cada proceso (en el panel: Para rescatar uno, abrir):

"liga_rescate": "https://admin.tu-tienda.mx/pedidos/{order_id}"

Cada renglón de «lo que se quedó sin cierre» que sigue abierto lleva entonces Rescatar, que abre ese pedido. El hueco se llama como la clave con la que se empareja el proceso ({order_id} para attrs.order_id, {entity} para entity) o, siempre, {clave}. La liga empieza con https://, el hueco no puede ir en el nombre del sitio y la clave va codificada: un pedido 48/9 abre …/pedidos/48%2F9. Por la API: liga_rescate al declarar o en un PATCH (null la quita), y cada renglón de GET /v1/expectations/:id/unmet trae su liga ya llena.

Despliegues

Cuando algo se rompe, la primera pregunta es «¿qué cambió?». Estiaje sabe cuándo desplegaste, qué y quién, por dos caminos que llegan al mismo lugar.

Solo, al arrancar

Desde la versión 0.5.0 del SDK, cuando tu proceso arranca mira la versión que dejó la plataforma en el entorno y la anuncia una vez. No escribes nada. Si la versión no cambió es un reinicio y no se cuenta; diez réplicas que arrancan con la misma versión son un solo despliegue.

La versión sale de (la primera que haya)Dónde
ESTIAJE_VERSIONla tuya: le gana a todo
VERCEL_GIT_COMMIT_SHAVercel, con quién, título y rama
RAILWAY_GIT_COMMIT_SHARailway, con quién, título y rama
RENDER_GIT_COMMITRender, con la rama
HEROKU_SLUG_COMMIT, SOURCE_VERSIONHeroku
K_REVISIONCloud Run
AWS_LAMBDA_FUNCTION_VERSIONLambda (menos $LATEST)
FLY_IMAGE_REFFly.io
npm_package_versiontu package.json, si arrancaste con npm start

Sin ninguna no se manda nada; tampoco sin ESTIAJE_TOKEN ni con NODE_ENV=test. GITHUB_SHA no cuenta aquí: GitHub Actions es el CI, no donde corre tu servicio (para eso está el paso de abajo). Se apaga con despliegues: false o ESTIAJE_DESPLIEGUES=0.

Con quién y qué, desde el CI

Una línea al final del pipeline:

npx @estiaje/sdk desplegado

Lee ESTIAJE_TOKEN y deduce del CI quién desplegó, el título del commit, la rama y el sha: GitHub Actions, GitLab, Bitbucket, CircleCI y Vercel, y si no, git. En GitHub Actions:

      - name: Avisar a Estiaje
        run: npx -y @estiaje/sdk desplegado --servicio mi-api
        env:
          ESTIAJE_TOKEN: ${{ secrets.ESTIAJE_TOKEN }}

      # y si algo falló antes, que también se sepa
      - name: Avisar a Estiaje que falló
        if: failure()
        run: npx -y @estiaje/sdk desplegado --servicio mi-api --fallo
        env:
          ESTIAJE_TOKEN: ${{ secrets.ESTIAJE_TOKEN }}

En GitLab CI, con ESTIAJE_TOKEN como variable enmascarada del proyecto:

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

La liga al commit. Desde la 0.8.0, con el despliegue viaja la dirección de tu repositorio (la del CI, la de Vercel o Railway, o el origin de git; ESTIAJE_REPO_URL le gana). Si la versión es un sha, el panel la liga a su commit en GitHub, GitLab o Bitbucket. Sin integraciones ni permisos: es solo la dirección, y nunca viaja con usuario ni clave.

BanderaQué hace
--servicio <nombre>El mismo de estiajeFromEnv({ service }). Sin ella: el name del package.json, o el repo
--version <v>Sin ella: el sha del commit
--titulo, --quienSi no quieres lo que dice el CI
--falloEl despliegue falló (para el paso con if: failure())
--silenciosoNo imprime nada si todo sale bien
Jamás rompe tu despliegue. Sale con 0 siempre: con Estiaje caído, sin red o sin llave, avisa en una línea y el pipeline sigue. Nunca imprime la llave. Usa el mismo nombre de servicio en el SDK y en el CI: así el aviso del CI y el arranque de esa versión cuentan como un solo despliegue.

Tu estado en tu sitio

Tu página de estado (estiaje.mx/estado/tu-negocio, desde el plan Inicio) también puede vivir dentro de tu propio sitio, en los planes Pro y Max: incrustada tal cual, o leyendo sus datos para pintarlos con tu diseño. Las dos dependen de la misma lista: los sitios que pueden mostrarla.

1

Publica la página en el panel, en Salud → Página de estado.

2

Elige qué servicios salen en Servicios en la página. Salen con su nombre; nunca direcciones ni errores.

3

Agrega tus sitios en Ponerla en tu sitio: escribe tu-negocio.mx y se guarda como https://tu-negocio.mx. Solo https, sin IPs ni comodines, hasta 10. Si tienes www. y sin www., agrega los dos.

Incrustada

Pega esto donde la quieras ver (el panel te lo da ya con tu dirección):

<iframe src="https://estiaje.mx/incrustar/tu-negocio" title="Estado de Tu Negocio"
        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>

Sale sin el encabezado de la página completa, con fondo transparente y un «Estado por Estiaje» discreto que abre la página entera en otra pestaña. El <script> es opcional: la página avisa su alto y el iframe se ajusta solo, sin barra de scroll. Es clara; si tu sitio es oscuro, agrega ?tema=oscuro a la dirección (o ?tema=auto para seguir al teléfono de quien la ve). Solo se deja meter en los sitios de tu lista; en cualquier otro, el navegador no la muestra.

Los datos, con tu diseño

La misma información que usa la página, en JSON, sin llave: GET https://estiaje.mx/v1/estado/tu-negocio

{
  "titulo": "Tu Negocio",
  "estado_general": "operando",      // o "falla_parcial", "caida"
  "actualizado": "2026-10-04T18:42:10.000Z",
  "servicios": [{
    "nombre": "Tienda en línea",
    "estado": "arriba",              // o "caida", "pausado"
    "uptime_90d": 99.97,             // null si aún no hay datos
    "dias": [{ "dia": "2026-07-07", "uptime": 100, "estado": "ok" }, /* … 90 días, el más viejo primero */]
  }],
  "incidentes": [{
    "servicio": "Tienda en línea",
    "inicio": "2026-09-28T03:12:00.000Z",
    "fin": "2026-09-28T03:19:00.000Z",  // null = sigue caído
    "duracion_min": 7
  }]
}

Cada día trae estado: "ok", "falla" (fallas breves), "caida" o "sin_datos". Si la página no está publicada responde 404.

Desde el navegador, en una página de uno de tus sitios (en cualquier otro sitio el navegador no la deja leer):

const r = await fetch('https://estiaje.mx/v1/estado/tu-negocio');
const estado = await r.json();
document.querySelector('#estado').textContent =
  estado.estado_general === 'operando' ? 'Todo funciona' : 'Tenemos fallas';

Desde tu servidor (Node 18 o más) funciona sin importar la lista. Guárdala 30 segundos: la respuesta no cambia más seguido.

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/tu-negocio',
    { signal: AbortSignal.timeout(3000) });
  if (!r.ok) return guardado;          // lo de antes, si había
  guardado = await r.json(); cuando = Date.now();
  return guardado;
}
Hasta 120 consultas por minuto desde una misma IP; de más, responde 429 con Retry-After. Desde el navegador de tus clientes no se nota (cada quien cuenta aparte); desde tu servidor, con la caché de 30 segundos te sobra.

Retención y cuotas

Los log son ruido y se borran según tu plan. Los event y outcome se conservan, porque son los que contestan preguntas de negocio meses después. Además hay un rollup por minuto que sobrevive al borrado: el volumen y la salud de cada servicio siguen ahí aunque el detalle ya no esté.

La cuota se cobra antes de leer el cuerpo de la petición — un lote enorme no te cuesta CPU si ya te pasaste. Si excedes, la puerta responde 429 con Retry-After, y el SDK reintenta solo.

API HTTP

El SDK es comodidad, no obligación. Todo se puede hacer con curl. La credencial va en Authorization: Bearer, y la organización sale siempre de la credencial — nunca del payload ni de un parámetro.

Mandar eventos

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"}}]'

Acepta un objeto o un arreglo. Responde 202 con {accepted, deduped, rejected[]}: un evento malo no tumba el lote — se rechaza solo él, con su índice y su razón.

Desde tu lenguaje

El SDK oficial es de Node/TypeScript. Para lo demás no hace falta esperar uno: es este mismo POST, mandado sin esperar la respuesta y con un timeout corto para que Estiaje nunca frene tu aplicación. Las recetas para Python, PHP, Java, C#, Go y Ruby, con su middleware para seguir el hilo entre servicios, están en ¿Funciona con tu stack?.

Consultar

EndpointPara qué
GET /v1/eventsBuscar. Filtra por q (prefijo del nombre), service, kind, severity, entity, trace, attr=clave:valor, con_geo=1. Rangos relativos: desde=-24h. Paginado por cursor.
GET /v1/traces/:traceUn viaje leído como historia: los pasos en orden, cuánto tardó entre uno y otro, y cuál fue el salto más largo.
GET /v1/servicesQuién está vivo: volumen, mezcla, errores y silencio por servicio.
GET /v1/latencyp50/p95/max de attrs.ms agrupado por cualquier etiqueta o columna. name=* = todo lo que traiga ms.
GET /v1/expectationsQué se está vigilando y cómo va cada una, con su valor y lo que está en riesgo en pesos.
GET /v1/liveSSE: el río en vivo, un resumen por segundo.

Dos tipos de credencial

TipoQuiénPuede
ck_live_…un servicioemitir y consultar. Se guarda cifrada y se muestra una sola vez.
cs_…una personaentrar al panel. Dura 30 días. No puede emitir.

Qué NO hacer

La versión de treinta segundos. Importa y emite: log para lo que pasó, event para lo que le importa al negocio, outcome para lo que termina un proceso. En los eventos de negocio incluye el identificador de la cosa. Y si tu código detecta que algo se truncó, dilo. Eso es todo.

Términos · Aviso de privacidad · hola@estiaje.mx