alepha@docs:~/framework/docs/guides/features$
cat 9-tracing.md | pretty
6 min read
Last commit:

#Tracing

Alepha traces itself. Every request, job run, cron tick and queue or topic message opens a trace, and the work inside it (actions, page loaders, queries, outbound calls, cache reads, lock waits) becomes its spans. There is no code to write: you choose where the spans go.

  • In development, devtools shows each trace as a waterfall.
  • On Node and Bun, set OTEL_EXPORTER_OTLP_ENDPOINT and spans are posted to any OpenTelemetry backend.
  • On Cloudflare Workers, spans join Cloudflare's own trace, beside the D1, KV and fetch spans the platform records.

With none of these, nothing is recorded and tracing costs a length check per instrumented call.

#What is traced

Span Opened by Kind
GET /users/:id a server request, named by route pattern server
action createUser each $action call, HTTP or local internal
loader home, render home each $page loader, and the SSR render internal
job sendDigest a $job run internal
cron sendDigest a cron tick internal
queue jobs, topic orders a queue or topic message consumer
GET api.example.com each HttpClient call client
select users, insert ... each SQL statement a repository runs internal
query a raw DatabaseProvider.execute() internal
cache get user-profile a $cache read, write or invalidate internal
lock acquire a $lock waiting for its lock internal

A request span ends when the response has been sent, so a streamed page counts its whole stream. A 5xx marks it failed; a 4xx does not. A thrown error is recorded on the span it escaped, with its type and message.

One repository method can call another (getById reads through findMany), but each statement is one span, named by its SQL verb and table. Five select order_items spans under one parent is the shape of an N+1, and devtools flags it.

Queries, cache operations and lock waits are only recorded inside a trace: a statement run at boot or by a poll loop opens no trace of its own.

#Log lines carry their trace

While a span is active, every log entry carries traceId and spanId. A JSON log line opens its trace, and devtools shows a span's own log lines under it.

json
1{2  "level": "INFO",3  "message": "Charging card",4  "traceId": "0af7651916cd43dd8448eb211c80319c",5  "spanId": "b7ad6b7169203331"6}

#Naming a business step

Tracer.span() wraps a block of your own code in a span. It nests under whatever is running, ends when the callback returns or settles, and records and rethrows an error.

typescript
 1import { $inject } from "alepha"; 2import { Tracer } from "alepha/tracing"; 3  4class Checkout { 5  protected readonly tracer = $inject(Tracer); 6  7  async pay(order: { id: string; items: string[] }) { 8    return this.tracer.span("checkout pay", async (span) => { 9      span.setAttribute("order.items", order.items.length);10      // ... the work11      return order.id;12    });13  }14}

Attributes leave the process. Record counts and categories, never personal data, tokens or ids that identify a person.

#Across services

HttpClient sends a W3C traceparent header with every call made inside a trace, and an Alepha server receiving one continues the caller's trace. Two Alepha services on Node or Bun therefore form a single trace.

A queue or topic message carries the producer's traceparent in its envelope. The consumer starts a trace of its own, linked to the producer's span rather than nested under it: a message can be handled long after the request that sent it ended.

#Exporting on Node and Bun

Point the OTLP exporter at a collector or a hosted backend. Every mainstream one accepts OTLP over HTTP directly, so no collector is needed.

bash
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.example.com/otlp
OTEL_EXPORTER_OTLP_HEADERS=authorization=Basic%20dXNlcjpwYXNz

Spans are posted as JSON to <endpoint>/v1/traces, in batches of 512 or every five seconds, and whatever is queued is sent when the app stops. The service name is the app's name (APP_NAME). A failed export is logged once and dropped: it never retries, never slows a request and never throws into your code.

#Sampling

TRACING_SAMPLE_RATIO (from 0 to 1, default 1) is the share of new traces recorded. It is decided once, at the root, and a trace continued from another service follows that service's decision. At the volumes most apps see, keep the default: record everything.

#On Cloudflare Workers

On a Worker, Alepha's spans are Cloudflare custom spans: the request span, your actions and queries sit in the same tree as the platform's D1, KV, R2, Durable Object and fetch spans, and log lines carry the ids the dashboard shows. There is no exporter of Alepha's own; Cloudflare's OTLP destinations export the whole trace.

Traces are opt in, because Cloudflare bills them as observability ingestion. Set TRACING_SAMPLE_RATIO when you build:

bash
TRACING_SAMPLE_RATIO=1 alepha build --runtime workerd

The build then writes observability.traces into wrangler.jsonc, with that ratio as its sampling rate. Without the variable, no traces key is written and nothing is recorded. An app that declares its own observability block keeps it untouched.

The build also records the ratio in the artifact's manifest.json, so a deploy that regenerates wrangler.jsonc from a built artifact, as a Lore deploy does, keeps traces on. A deployed copy can set TRACING_SAMPLE_RATIO in its own environment too: that value wins over the recorded one, which turns traces on, off (0) or to another rate without a rebuild.

No traceparent leaves a Worker: Cloudflare does not say whether a request is sampled, and its trace ids are not propagated outside Cloudflare.

#Reading traces in devtools

Under alepha dev, open devtools and pick Traces: the last two hundred traces of the app, newest first, with a filter for failed ones. A trace opens as a waterfall; a span shows its timing, attributes, error and log lines. A parent of five or more same-name spans carries an N+1 warning.

#What is never recorded

  • Bound SQL values, the SQL text, or a where object.
  • A raw URL, a query string or a header: a request span holds the route pattern, an HttpClient span the host.
  • A cache key: a cache span names the cache.
  • A computed lock name: lock.name is recorded only when the name is a static string.

#Time on Workers

On Cloudflare Workers the clock only advances across I/O. A span that only computes measures 0 ms there, however long it took. Durations of spans that wait on a query or a fetch are accurate.