Observability · how we see inside our apps

Observability · how it works

Follow one name all the way

A name is written once in the contract. obs turns it into a typed constant per language. Your code passes that constant to a port. The adapter sends it to Alloy, Alloy stores it, and Grafana queries, alerts and draws it.

  • Pick a signal and a language below; every box and code sample redraws. PROPOSED · this site
  • The code is the shape from the design record. None of it is built yet. DECIDED · A193 tab 3

Pick a signal and a language

Signal

Language

The 7 stops of one name1CONTRACT2GENERATED NAME3PORT CALL4ADAPTER5TRANSPORT6STORE7QUERY / ALERT

Log · Bun

  1. 1 · Contract

    Written by hand, once per service. DECIDED · A193 tab 3

    events:
      checkout.charge.failed: { level: error, description: A card charge did not succeed }
  2. 2 · Generated name

    as const keeps the exact string type; one list cannot drift. DECIDED · A193 tab 3

    // src/observability/signals.g.ts
    export const Event = {
      CheckoutChargeFailed: 'checkout.charge.failed',
    } as const;
    export type Event = (typeof Event)[keyof typeof Event];
  3. 3 · Your code calls the port

    Only typed names are accepted; labels are free-form. DECIDED · A193 tab 1 #5; #7

    if (!r.ok) this.t.logger.error(
      { event: Event.CheckoutChargeFailed, 'error.type': r.error.code },
      'charge failed',
    );
  4. 4 · Adapter

    sdk-logs LoggerProvider + OTLP exporter. Adds durian.cloud/event and the OTLP EventName field, the trace and span ids, and the LPSM keys. pino stays for the console. DECIDED · A193 tab 4

  5. 5 · Transport

    OTLP → Alloy → loki:3100/otlp TESTED · Q15

  6. 6 · Store

    Loki. The event key is stored as durian_cloud_event="checkout.charge.failed". PROPOSED · A193 tab 1, jaeda call 10

  7. 7 · Query, alert, dashboard

    LogQL. Alert query B in charge-errors/critical.yaml. DECIDED · A193 tab 2

    sum(count_over_time({service_name="checkout"}
      | durian_cloud_event="checkout.charge.failed" [5m])) > 10

The 7 stops, in words

  1. Contract. You write the name once, by hand. Names are lowercase and dotted, and unique across kinds. Example: checkout.charges cannot be both a counter and an event. DECIDED · A193 tab 3 PROPOSED · A193 tab 1, jaeda call 1
  2. Generated names. obs gen writes one file of constants per service. Same contract → same bytes. Constants only, never functions. DECIDED · A193 tab 1 #7
  3. Port call. Your code calls a port with the constant. Labels are free-form. The trace context travels by itself. DECIDED · A193 tab 3
  4. Adapter. The *-otel library turns the call into OpenTelemetry. It adds the event key, the 4 LPSM keys (where the signal came from: landscape, platform, service, module) and the error details. DECIDED · A193 tab 4
  5. Transport. Logs, metrics and spans go as OTLP to Alloy. Profiles go as pprof to Alloy. Flutter goes through Faro to Alloy. DECIDED · A193 tab 5; A193 tab 1 #21
  6. Store. Loki, VictoriaMetrics, Tempo or Pyroscope. Each store spells names its own way. Example: checkout.charges is stored as checkout_charges_total. PROPOSED · A193 tab 1, jaeda call 10
  7. Query, alert, dashboard. Grafana reads the store. Alerts and dashboards live in observability/ and obs checks them. DECIDED · A193 tab 2

How a wrong name fails

  • Bun: a log without an event → TS2345: Property 'event' is missing. DECIDED · A193 tab 3
  • Bun: a counter passed where a histogram is needed → TS2345: '"checkout.charges"' is not assignable to parameter of type 'Histogram'. DECIDED · A193 tab 3
  • Go: building a name outside the generated file → implicit assignment to unexported field name in struct literal. TESTED · Q7, evidence.txt
  • Go: a plain string → cannot use "…" (untyped string constant) as signals.Event value. DECIDED · A193 tab 3 NOT TESTED · not in the spike evidence
  • Go's one hole: an empty signals.Event{} compiles. A forbidigo lint rule or a short analyzer catches it; the port also errors on an empty name. TESTED · Q7
  • Only Go's closed names were spiked. The Bun, .NET and Dart error texts come from the design record, not a run. DECIDED · A193 tab 3 NOT TESTED · not run
  • .NET: new Event("x") → CS0122: inaccessible. DECIDED · A193 tab 3
  • Dart: a histogram passed as an event → The argument type 'Histogram' can't be assigned to the parameter type 'Event'. DECIDED · A193 tab 3

Errors in a Result mark the span

  • When your function returns Err(problem), tracer.run marks the span. No exception needed. DECIDED · A193 tab 1 #15 TESTED · Q5; Q11
  • It sets span status Error, adds a problem event with the RFC 9457 fields, and sets error.type. TESTED · Q5; Q11
  • Example: fetchUser(42) returns Err({ type: "…/suspended", status: 403 }) → span Error 403, a problem event, error.type=…/suspended. TESTED · Q5
  • The helper is small in each of Bun, Go and .NET (19 lines in Bun, 12 in Go). TESTED · Q5; Q11
  • A telemetry failure never changes your result. PROPOSED · A193 tab 1, jaeda call 5

Spans you get for free (auto spans)

The contract opts in with a list. Example: auto: [http.server, http.client, db.postgres, db.redis, runtime]. DECIDED · A193 tab 1 #18

Auto spanBunGo.NET
HTTP inour instrumentServe wrapperotelhttp.NewHandlerASP.NET Core instrumentation
HTTP outour instrumentedFetchotelhttp.NewTransportHttpClient instrumentation
Postgresinstrumentation-pg ✓otelpgx ✓ (2 spans per query)Npgsql built in ✓
Redisour wrapper (pick 5, decided)redisotel ✓ (old attribute names)contrib package ✓, errors not marked: we fix
  • Bun's Redis client and Bun SQL make no spans at all, so lib/bun/otel gets thin wrappers. Example: redis.get("cart:42") → span redis GET. TESTED · Q6 DECIDED · A193 tab 1 #20
  • Example trace: GET /checkout (auto) → checkout.charge (yours) → POST api.stripe.com (auto). The log checkout.charge.failed sits under checkout.charge. DECIDED · A193 tab 4
What changes in each library
  • New major of each *-interfaces (Bun, Go, .NET, Dart): the 4 ports, name interfaces, in-memory test doubles, the banned-label policy. DECIDED · A193 tab 6 round 1a
  • The tracer moves from *-otel into *-interfaces. Today it is TraceEmitter. DECIDED · A193 tab 1 #6
  • New major of each *-otel (Bun, Go, .NET): Result helper, DB spans, profiling, LPSM keys. DECIDED · A193 tab 6 round 2a
  • New lib/dart/faro behind the Dart ports. DECIDED · A193 tab 1 #12
  • Go ports take ctx first. Today's Emit(record) uses context.Background() and loses the trace. PROPOSED · A193 tab 1, jaeda call 3
  • C# ports are named ISignal*, so they do not clash with ILogger. PROPOSED · A193 tab 1, jaeda call 4
  • Bun: NodeSDK 0.301 sent 0 spans under Bun, so the adapter builds the providers by hand. TESTED · A193 tab 4, spike js
  • .NET: the template must first move to Host.CreateApplicationBuilder. DECIDED · A193 tab 4

All pages