Arrange Act Assert

Jag Reehals thinking on things, mostly product development

Logging Sucks. Autotel Makes Wide Events the Default

08 Jan 2026

Boris is spot on in his Logging Sucks post

logs are optimised for writing, not querying

He explains why debugging in production feels like archaeology.

You grep for user-123, find it logged 47 different ways, then spend an hour correlating timestamps across services.

We've all been there.

His wide event example nails it:

{
  "user": {"id": "user_456", "subscription": "premium", "lifetime_value_cents": 284700},
  "cart": {"item_count": 3, "total_cents": 15999, "coupon_applied": "SAVE20"},
  "payment": {"method": "card", "provider": "stripe", "latency_ms": 1089},
  "error": {"type": "PaymentError", "code": "card_declined", "stripe_decline_code": "insufficient_funds"}
}

One event. High-cardinality keys (user.id, traceId). High dimensionality. Queryable.

But there’s still a gap…

I built Autotel to close that gap.

Autotel makes wide events the default output of traces, producing one canonical structured log per request, chosen via tail sampling.

By default, Autotel emits a canonical log line for the root/server span (and optionally for spans that match custom filters such as status=ERROR or duration_ms > 2000), with tail sampling applied automatically.

The middleware-based approach still requires wiring, manual context accumulation, and custom sampling logic.

It works, but it’s a lot of ceremony for something we all want to be the default.

The Problem You’re Solving

A single checkout produces scattered logs:

2024-12-20T10:23:45.100Z INFO  Checkout started userId=user-123
2024-12-20T10:23:45.150Z DEBUG Cart loaded cartId=cart-1 itemCount=2
2024-12-20T10:23:45.300Z INFO  Payment processing method=card provider=stripe
2024-12-20T10:23:45.450Z DEBUG Validating payment details
2024-12-20T10:23:45.612Z ERROR Payment failed error=card_declined userId=user-123

To debug, you grep for userId, then cartId, then correlate timestamps across services.

One Wide Event. Zero Per-Endpoint Wiring

With Autotel, the same checkout produces a single canonical log line (a wide event). No per-endpoint middleware wiring, no finally blocks, no string logs:

{
  "level": 50, // Pino numeric level (50=error, 30=info)
  "name": "checkout-api",
  "msg": "[HTTP POST /api/checkout] Request completed",
  "http.request.method": "POST",
  "http.route": "/api/checkout",
  "http.response.status_code": 500,
  "user.id": "user-123",
  "user.subscription": "premium",
  "user.account_age_days": 847,
  "user.lifetime_value_cents": 284700,
  "request.id": "req_123",
  "cart.id": "cart-1",
  "cart.item_count": 2,
  "cart.total_cents": 7998,
  "cart.coupon_applied": "SAVE20",
  "payment.method": "card",
  "payment.provider": "stripe",
  "payment.latency_ms": 150,
  "payment.attempt": 1,
  "error.type": "PaymentError",
  "error.code": "card_declined",
  "error.message": "Card declined by issuer",
  "error.stripe_decline_code": "insufficient_funds",
  "service.name": "checkout-api",
  "service.version": "1.0.0",
  "deployment.environment": "development",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "correlationId": "4bf92f3577b34da6",
  "duration_ms": 512.3,
  "status_code": 2, // OpenTelemetry StatusCode (1=OK, 2=ERROR)
  "status_message": "Payment failed: card_declined",
  "timestamp": "2024-12-20T10:23:45.612Z"
}

Examples are JSONC for readability. The status_code field uses OpenTelemetry's StatusCode enum (OK=1, ERROR=2); for errors, status_message provides a human-readable description.

Use attributeRedactor in production to redact PII.

One event with 25+ fields, emitted automatically when the root span completes.

All attributes use dot-notation (e.g., user.id) rather than nested objects. Most log stores flatten nested objects into dotted keys anyway.

Autotel emits that shape directly.

Which spans become wide events?

By default, Autotel emits canonical logs for root/server spans (typically your HTTP request handlers).

You can configure additional spans via filters (e.g., by span.kind, minimum duration, or status).

This avoids a log volume explosion from every nested span.

Limits & safety rails

OpenTelemetry SDKs enforce attribute limits (defaults vary by SDK and are configurable). Autotel respects SDK limits and truncates when exceeded. For large payloads, store IDs and pointers rather than full objects. Use attributeRedactor to redact PII before emission.

Be intentional about which high-cardinality fields you index vs store: index a small set, store the rest.

Manual Wide-Event Assembly vs Autotel

Manual middleware-based approach builds the event manually:

// Manual approach: middleware + manual accumulation
export function wideEventMiddleware() {
  return async (ctx, next) => {
    const startTime = Date.now();
    const event = { 'request.id': ctx.get('requestId') };
    ctx.set('wideEvent', event);

    try {
      await next();
    } finally {
      event.duration_ms = Date.now() - startTime;
      logger.info(event);
    }
  };
}

// Then in every handler:
app.post('/checkout', async (ctx) => {
  const event = ctx.get('wideEvent');
  // Manual approach often ends up with nested blobs that get flattened inconsistently
  event.user = { id: user.id, subscription: user.subscription };
  event.cart = { id: cart.id, item_count: cart.items.length };
  // ...more manual accumulation
});

With Autotel, context accumulates naturally:

import { trace, setUser, httpServer } from 'autotel';

export const processCheckout = trace((ctx) => async (req: CheckoutRequest) => {
  const user = await getUser(req.userId);
  
  // Standard user attributes via setUser
  setUser(ctx, {
    id: user.id,
    // email: user.email, // Redact PII in production
  });

  // Custom user attributes via setAttributes
  ctx.setAttributes({
    'user.subscription': user.subscription,
    'user.account_age_days': user.accountAgeDays,
    'user.lifetime_value_cents': user.lifetimeValueCents,
  });

  httpServer(ctx, { method: 'POST', route: '/api/checkout' });

  const total = req.items.reduce((sum, item) => sum + item.price, 0);
  ctx.setAttributes({
    'cart.id': req.cartId,
    'cart.item_count': req.items.length,
    'cart.total_cents': Math.round(total * 100),
    'cart.coupon_applied': req.coupon,
  });

  const payment = await processPayment(req);

  ctx.setAttributes({
    'payment.method': payment.method,
    'payment.provider': payment.provider,
    'payment.latency_ms': payment.latencyMs,
  });

  if (payment.error) {
    ctx.setAttributes({
      'error.type': 'PaymentError',
      'error.code': payment.error.code,
      'error.can_retry': payment.error.canRetry,
    });
    throw new PaymentError(payment.error.message);
  }

  return { orderId: payment.orderId };
});
// On span end, Autotel emits one structured log line via the configured exporter

No per-endpoint middleware. No ctx.get('wideEvent'). No finally block. (You still need init() setup and exporter configuration, but that's one-time, not per-endpoint.)

By default, this can go to stdout (JSON) or to an OTLP/HTTP exporter, depending on your setup.

Manual Wide-Event Assembly Autotel Approach
Build a separate event object Use the span as the event source
Middleware to create/attach context trace() creates context per function
Mutate ctx.get('wideEvent') ctx.setAttributes() on the span
Emit in finally block Emit on span completion
Custom shouldSample() function Built-in tail sampler

On "OTel Won't Save You"

He's right.

Most OTel setups capture span name, duration, and maybe recordException(error). That's baseline instrumentation: useful, but it won't give you queryable context.

Autotel’s trace() wrapper makes rich instrumentation the path of least resistance.

You’re already inside the traced function, so adding ctx.setAttributes() is natural. Context accumulates as you process, not in a separate middleware layer.

Tail Sampling Without the Logic

His sampling function:

function shouldSample(event) {
  if (event.status_code >= 500) return true;
  if (event.duration_ms > 2000) return true;
  if (event.user?.subscription === 'enterprise') return true;
  return Math.random() < 0.05;
}

With Autotel:

import { init, AdaptiveSampler } from 'autotel';

init({
  service: 'checkout-api',
  sampler: new AdaptiveSampler({
    baselineSampleRate: 0.05,
    alwaysSampleErrors: true,
    alwaysSampleSlow: true,
    slowThresholdMs: 2000,
  }),
});

Same logic, zero custom code. The sampler evaluates after the span completes (tail sampling), so it sees duration, status, and all attributes before deciding.

Autotel’s sampler decides whether to emit the canonical log line for each root span; it’s not trying to guarantee whole-trace retention.

Try It Yourself

If you want to see the shape firsthand, here’s a 60-second demo.

Quickstart: clone the repo and run the demo:

git clone https://github.com/jagreehal/autotel
cd autotel/apps/example-canonical-logs
pnpm install && pnpm start:server
# In another terminal: ./demo.sh

The demo accepts request_id in the request body, then records it on the span as request.id so the emitted logs stay dot-notated.

Request IDs:

The Queries You Want

SELECT * FROM logs
WHERE user.subscription = 'premium'
  AND error.type IS NOT NULL
  AND feature_flags.new_checkout_flow = true;

Failed payments by decline code:

SELECT error.code, COUNT(*) as count
FROM logs
WHERE error.type = 'PaymentError'
GROUP BY error.code;

Slow checkouts for high-value customers:

SELECT * FROM logs
WHERE duration_ms > 500
  AND user.lifetime_value_cents > 100000;

Each curl call adds context to the span. Watch the server console on the final step, one canonical log line with everything:

{
  "level": 30,
  "name": "checkout-api",
  "http.request.method": "POST",
  "http.route": "/api/checkout",
  "http.response.status_code": 200,
  "user.id": "user_456",
  "user.subscription": "premium",
  "user.account_age_days": 847,
  "user.lifetime_value_cents": 284700,
  "request.id": "req_test123",
  "cart.id": "cart_xyz",
  "cart.item_count": 3,
  "cart.total_cents": 15999,
  "cart.coupon_applied": "SAVE20",
  "payment.method": "card",
  "payment.provider": "stripe",
  "payment.latency_ms": 241,
  "payment.attempt": 1,
  "service.name": "checkout-api",
  "service.version": "1.0.0",
  "deployment.environment": "development",
  "traceId": "24e8e520137c5cdcdd59ffd5f653d870",
  "spanId": "ed53a2d659907201",
  "correlationId": "24e8e520137c5cdc",
  "duration_ms": 1.04,
  "status_code": 1,
  "timestamp": "2026-01-08T00:26:08.836Z",
  "msg": "[HTTP POST /api/checkout] Request completed"
}

The demo supports manual curl commands too:

Step 1: Initialize request

curl -X POST http://localhost:3000/checkout/start \
  -H "Content-Type: application/json" \
  -d '{"request_id": "req_123"}'

Step 2: Add user context

curl -X POST http://localhost:3000/checkout/auth \
  -H "Content-Type: application/json" \
  -d '{"request_id": "req_123", "user_id": "user_456"}'

Step 3: Add cart context

curl -X POST http://localhost:3000/checkout/cart \
  -H "Content-Type: application/json" \
  -d '{"request_id": "req_123", "cart_id": "cart_xyz"}'

Step 4: Process payment

curl -X POST http://localhost:3000/checkout/payment \
  -H "Content-Type: application/json" \
  -d '{"request_id": "req_123"}'

Step 5-6: Complete + emit canonical log

curl -X POST http://localhost:3000/checkout/complete \
  -H "Content-Type: application/json" \
  -d '{"request_id": "req_123"}'

Repo: github.com/jagreehal/autotel

logging autotel OpenTelemetry