diff --git a/compiler/impl/codegen/stmt.xi b/compiler/impl/codegen/stmt.xi index 077d5a2..330b974 100644 --- a/compiler/impl/codegen/stmt.xi +++ b/compiler/impl/codegen/stmt.xi @@ -44,8 +44,26 @@ mapper genIf(toks: Token[], pos: Integer, ctx: GCtx) -> StmtRes { if e.xtyp.startsWith2("opt_") { nmType = string_slice(e.xtyp, 4, string_len(e.xtyp)).xnameFromArrSuffix() } let bctx = ctx.addSym(nm, nmType) let body = " __auto_type " + nm + " = (" + e.code + ").value;\n" + genStmts(toks, pe + 1, close, bctx) - let code = " if ((" + e.code + ").has_value) {\n" + body + " }\n" - return StmtRes { code: code, ctx: ctx, pos: close + 1 } + let code = " if ((" + e.code + ").has_value) {\n" + body + " }" + // The bound name is in scope only in the then-branch; the else-branch (if + // any) runs in the outer ctx. Without this, a trailing `else { … }` was + // parsed as a stray statement and ran unconditionally. + let np = close + 1 + if toks.kindAt(np) == 223 { // else + if toks.kindAt(np + 1) == 222 { // else if + let inner = genIf(toks, np + 1, ctx) + code = code + " else " + inner.code + np = inner.pos + } else { + let eclose = toks.matchBrace(np + 1) + let ebody = genStmts(toks, np + 2, eclose, ctx) + code = code + " else {\n" + ebody + " }\n" + np = eclose + 1 + } + } else { + code = code + "\n" + } + return StmtRes { code: code, ctx: ctx, pos: np } } let c = genExpr(toks, p, ctx) let pc = c.pos diff --git a/docs/tracing.md b/docs/tracing.md new file mode 100644 index 0000000..df83d0c --- /dev/null +++ b/docs/tracing.md @@ -0,0 +1,487 @@ +# std/tracing: OpenTelemetry-compatible distributed tracing + +**Status: all stages implemented.** Tracing core, W3C propagation, the OTLP/HTTP +exporter with typed config, ratio/off samplers, the monitoring bridge and web +server-span helper, trace-correlated logging, and the metric instruments (under +`std/monitoring/instruments.xi`) are all in place, each with tests and, where it +runs, an example. Every mechanic is a bindable interface. + +`std/tracing` gives an Xi program the tracing signal of OpenTelemetry: spans with +a trace/span identity, parent-child nesting, attributes, events, status, a +sampler, context propagation across process boundaries (W3C Trace Context), and +exporters (console, in-memory, and OTLP over HTTP to any OpenTelemetry +collector). It sits **alongside** `std/monitoring`: monitoring answers "is this +process healthy right now" with gauges and health checks, and tracing answers +"what happened on this one request, across every service it touched". A bridge +lets traced spans surface in the existing `/monitor/metrics` report so the two +are one story. + +It follows the same rules as `std/monitoring`: it is a library of interfaces you +opt into, and it is **off until you enable it**. An app that imports nothing +carries no tracing surface; an app that imports but never calls `enable()` pays +nothing at runtime. + +A useful property of the design: it needs **no new C or runtime code**. Every +primitive is already in the standard library: `crypto.randomHex` for ids, +`time.nowNanos` for timing, `std/http` for OTLP export and header propagation, +`std/json` for the payload, and the injected `Logger` for correlation. The whole +module is portable Xi. + +## Where metrics and logs fit + +"Full OpenTelemetry" is three signals. This module owns **traces**. The other +two are addressed without pulling them under a "tracing" name: + +- **Metrics** already have a home. `std/monitoring` exposes `monitor.gauge(...)` + and the `Monitoring` interface. The richer OTel instruments (counter, up-down + counter, histogram) are a natural follow-up as `std/monitoring/instruments.xi`, + and the OTLP exporter here is written so the same transport can carry metric + data later. +- **Logs** are correlated, not replaced. `std/tracing` provides a `TraceContext` + accessor and a thin `Logger` decorator that stamps every line with the active + `trace_id` / `span_id`, so existing `logger.info(...)` calls join the trace + without changing call sites. + +The rest of this document is the tracing design. + +## Enabling it (the developer experience first) + +```x +import "std/tracing.xi" + +async entry (tracer: Tracer, tracing: TracingRuntime as singleton) main(args: String[]) -> Integer { + tracing.enable() // nothing is live until this runs + + let span = tracer.start("checkout", Server) + tracer.setAttribute(span, "user.id", "42") + // ... work ... + tracer.addEvent(span, "payment.authorized") + tracer.end(span) // span is sampled, then exported + + tracing.flush() // force any buffered export + return 0 +} + +module App { + // Choose where spans go. Omit to default to the console exporter. + bind SpanExporter -> OtlpHttpSpanExporter as singleton + bind Sampler -> RatioSampler as singleton +} +``` + +The endpoint, service name, and sample ratio come from config (see +[Configuration](#configuration)), so switching from console to a real collector +is a config change, not a code change. + +### Replacing the defaults + +Every mechanic is an interface, so a program overrides any one with a `bind`, +and the compiler injects the replacement everywhere the default would have gone: + +```x +module App { + bind Tracer -> MyTracer as singleton // replace DefaultTracer wholesale + bind SpanExporter -> MyExporter as singleton // send spans somewhere else + bind Sampler -> MySampler // decide what to record + bind IdGenerator -> MyIdGenerator // supply your own ids + bind TraceClock -> MyClock // control time (tests) +} +``` + +The defaults (`DefaultTracer`, `ConsoleSpanExporter`, `AlwaysOnSampler`, +`RandomIdGenerator`, `SystemTraceClock`) are ordinary implementations with no +special status; a bound alternative simply wins. This is what the test suite +does to inject an in-memory exporter and deterministic samplers. + +## Core model (domain types) + +All of these are plain value types in `std/tracing/model.xi`. They are data; the +behavior lives in the service layer. + +```x +// The propagatable identity of a span. traceId is 16 bytes (32 hex chars), +// spanId is 8 bytes (16 hex chars), matching the OpenTelemetry wire format. +type SpanContext = { + traceId: String, + spanId: String, + sampled: Bool +} + +type SpanKind = | Internal | Server | Client | Producer | Consumer +type StatusCode = | Unset | Ok | Error + +type Attribute = { key: String, value: String } // string-valued in v1 +type SpanEvent = { name: String, timeNanos: Integer, attributes: Attribute[] } + +// A finished span, ready to export. Held by the runtime, not by user code. +type SpanData = { + context: SpanContext, + parentSpanId: String, // "" when this is a root span + name: String, + kind: SpanKind, + startNanos: Integer, + endNanos: Integer, + status: StatusCode, + statusMsg: String, + attributes: Attribute[], + events: SpanEvent[] +} + +// Describes the entity producing the telemetry (OTel Resource). +type Resource = { serviceName: String, attributes: Attribute[] } + +// The lightweight handle user code holds. It is identity only; all mutation +// goes through the Tracer, which owns the span's state (see rationale below). +type Span = { context: SpanContext, id: String } +``` + +`Attribute.value` is a `String` in the first version. Typed attribute values +(bool / int / double / array) are an additive follow-up; the OTLP encoder +already wraps values in a typed envelope, so widening later does not break the +wire format. + +## Ports (interfaces) + +`std/tracing/ports.xi`. Every seam is an interface so it can be bound and faked. + +```x +// What application code uses. One span per unit of work. +interface Tracer { + producer start(name: String, kind: SpanKind) -> Span // root or child of current + producer startChild(parent: SpanContext, name: String, kind: SpanKind) -> Span + consumer setAttribute(span: Span, key: String, value: String) + consumer addEvent(span: Span, name: String) + consumer setStatus(span: Span, code: StatusCode, message: String) + consumer recordError(span: Span, message: String) // event + Error status + mapper contextOf(span: Span) -> SpanContext + consumer end(span: Span) // finish + hand to processor +} + +// Receives finished spans. Adapters: console, in-memory, OTLP/HTTP. +interface SpanExporter { + producer export(spans: SpanData[]) -> Bool // true on success + consumer shutdown() +} + +// Head-based sampling decision, made once per trace at the root. +interface Sampler { + predicate shouldSample(traceId: String, name: String, kind: SpanKind) -> Bool + mapper describe() -> String // for diagnostics +} + +// Seams kept separate so tests get deterministic ids and time. +interface IdGenerator { producer newTraceId() -> String producer newSpanId() -> String } +interface TraceClock { producer nowNanos() -> Integer } + +// W3C Trace Context across process boundaries. +interface Propagator { + mapper inject(ctx: SpanContext) -> String // -> traceparent header value + mapper extract(traceparent: String) -> SpanContext? // none if absent/malformed +} + +// The composition object the app enables and flushes. Holds the resource, +// the active/finished spans, and drives the processor/exporter. +interface TracingRuntime { + consumer enable() + predicate isEnabled() -> Bool + consumer flush() // force export of buffered spans + consumer shutdown() + mapper spanCount() -> Integer // spans finished this process +} +``` + +## The service layer + +`std/tracing/runtime.xi`. One class, bound `as singleton`, owns all span state +and orchestrates sampling and export. This is the only stateful piece. + +```x +class DefaultTracingRuntime implements TracingRuntime, Tracer { + deps { + exporter: SpanExporter, + sampler: Sampler, + ids: IdGenerator, + clock: TraceClock, + config: TracingConfig + } + state { + on: Bool = false + current: SpanContext? // innermost active span (sync convenience) + active: Map // spanId -> in-progress span + finished: List // buffer flushed to the exporter + } + + consumer enable() { this.on = true } + predicate isEnabled() -> Bool => this.on + + producer start(name: String, kind: SpanKind) -> Span { + // root, or child of `current` if one is active + ... + } + consumer end(span: Span) { + // stamp endNanos, move active -> finished, set current back to parent, + // and when the buffer reaches the batch size, export it. + ... + } + consumer flush() { exporter.export(this.finished.toArray()) this.finished.clear() } +} +``` + +Two processor behaviors, chosen by config: + +- **Simple**: export each span the moment it ends. Lowest latency, most calls. +- **Batch** (default): buffer finished spans and flush when the batch fills or on + `flush()` / `shutdown()`. Fewer, larger exports. + +### Why the API is tracer-centric, not span-centric + +OpenTelemetry's own API reads `span.setAttribute(...)`. Xi's dependency injection +resolves one instance of a class; it has no idiomatic way to hand each +dynamically created span its own injected exporter. So a span here is a small +value (`type Span`), and the mutating operations live on the injected `Tracer`, +which owns the state: `tracer.setAttribute(span, ...)`. This keeps all mutable +state in one singleton, keeps every seam injectable and fakeable, and avoids +class literals that skip state initialization. A fluent `span.setAttribute(...)` +sugar via extension methods can be added later if it proves worth the indirection. + +## Context propagation (W3C Trace Context) + +`std/tracing/propagation.xi` implements the standard `traceparent` header so a +trace continues across services: + +``` +traceparent: 00-<32 hex trace id>-<16 hex span id>-<2 hex flags> + ^version ^trace-id ^parent-id ^01 = sampled +``` + +**Outgoing** (this service calls another): inject the current context and send it +as a header through `std/http`. + +```x +let child = tracer.start("GET inventory", Client) +let hdr = propagator.inject(tracer.contextOf(child)) // "00---01" +let resp = http.requestWith("GET", url, "", "", mapOf("traceparent" to hdr)) +tracer.end(child) +``` + +**Incoming** (a web handler): extract the parent from the request headers and +start the server span as its child. + +```x +action handle(req: HttpRequest, res: HttpResponse) where web.route(req, "GET", "/orders/:id") { + let parent = propagator.extract(web.headers(req).getOr("traceparent", "")) + let span = if let p = parent { tracer.startChild(p, "GET /orders/:id", Server) } + else { tracer.start("GET /orders/:id", Server) } + // ... handle ... + tracer.end(span) +} +``` + +`std/http` gains one additive helper, `requestWith(..., headers: Map)`, +to carry the injected header. Nothing about `traceparent` is special to it. + +## Exporters + +`std/tracing/exporter/`. All implement `SpanExporter`; choose one with a `bind`. + +- **`ConsoleSpanExporter`** (default): prints each span as one JSON line via the + `Logger`. Zero setup, for local development. +- **`InMemorySpanExporter`**: keeps finished spans in a `List` and exposes them + for assertions. The key to unit-testing instrumentation. +- **`OtlpHttpSpanExporter`**: POSTs OTLP/JSON to + `/v1/traces` (default `http://localhost:4318/v1/traces`) via + `std/http`. This is the real OpenTelemetry wire, understood by the Collector, + Jaeger, Tempo, and the vendor backends. + +### OTLP/JSON mapping + +`SpanData` maps to OTLP `ResourceSpans` like this (built with `std/json`): + +```json +{ + "resourceSpans": [{ + "resource": { + "attributes": [{ "key": "service.name", "value": { "stringValue": "checkout" } }] + }, + "scopeSpans": [{ + "scope": { "name": "std/tracing", "version": "" }, + "spans": [{ + "traceId": "<32 hex>", "spanId": "<16 hex>", "parentSpanId": "<16 hex or empty>", + "name": "checkout", "kind": 2, + "startTimeUnixNano": "1690000000000000000", + "endTimeUnixNano": "1690000000123000000", + "attributes": [{ "key": "user.id", "value": { "stringValue": "42" } }], + "events": [{ "name": "payment.authorized", "timeUnixNano": "..." , "attributes": [] }], + "status": { "code": 1 } + }] + }] + }] +} +``` + +Enum encodings (from the OTLP spec): + +| Xi `SpanKind` | OTLP kind | Xi `StatusCode` | OTLP status code | +| ------------- | --------- | --------------- | ---------------- | +| Internal | 1 | Unset | 0 | +| Server | 2 | Ok | 1 | +| Client | 3 | Error | 2 | +| Producer | 4 | | | +| Consumer | 5 | | | + +Times are unsigned nanoseconds since the Unix epoch, stringified (OTLP/JSON +encodes 64-bit ints as strings). `time.nowNanos()` supplies them directly. + +## Sampling + +`std/tracing/sampler.xi`: + +- **`AlwaysOnSampler`** (default): sample everything. Correct for low volume. +- **`AlwaysOffSampler`**: sample nothing (tracing structurally on, output off). +- **`RatioSampler`**: sample a fixed fraction, decided from the trace id so the + choice is stable for the whole trace. Ratio comes from config. + +Sampling is head-based and decided once at the root; children inherit the +root's `sampled` flag through the propagated context, so a trace is all-or-nothing +across services. + +## Integration with std/monitoring + +`std/tracing/monitoring.xi` bridges the two so tracing shows up where operators +already look, without either module depending on the other's internals: + +```x +class TracingMonitoring implements Monitoring { + deps { tracing: TracingRuntime as singleton } + mapper name() -> String => "tracing" + consumer startMonitor() { } + producer healthy() -> Bool => true + producer metrics() -> Json { + let o = json.object() + o = json.set(o, "spans", json.int(tracing.spanCount())) + o = json.set(o, "enabled", json.bool(tracing.isEnabled())) + return o + } +} +``` + +Import it and the span count joins `/monitor/metrics` under `"tracing"`, exactly +like `WebMonitoring` adds `"web"`. + +## Web auto-instrumentation + +`std/tracing/web.xi` offers a ready-made server span per request so apps do not +hand-instrument every handler. Xi web dispatch is a set of `where`-guarded +handlers rather than a middleware chain, so the module provides two forms: + +- **A wrapper helper** `traced(req, res, name) { ... }` that starts a server span + (extracting any inbound `traceparent`), runs the block, records the response + status, and ends the span. Explicit, no framework hook required. +- **A catch-all `TracingHandler`** bound ahead of app handlers that opens the + span, stores its context for the request, and lets the matching handler run + inside it. This depends on a small ordering/where-guard capability; if that + proves awkward, the wrapper is the supported path and the catch-all is + documented as best-effort. + +Standard HTTP semantic-convention attributes (`http.request.method`, +`http.route`, `http.response.status_code`, `url.path`) are set automatically. + +## Configuration + +`std/tracing/config.xi` reads typed config so deployment is not a recompile: + +```x +type TracingSettings = { + serviceName: String, // -> Resource service.name (default: module id) + exporter: String, // "console" | "otlp" | "none" (default: "console") + endpoint: String, // OTLP base URL (default: "http://localhost:4318") + sampleRatio: Number, // 0.0 .. 1.0 for RatioSampler (default: 1.0) + batch: Bool // batch vs simple processor (default: true) +} + +interface TracingConfig { mapper tracing() -> TracingSettings } + +module App { bind TracingConfig -> readConfig("application.yaml") } +``` + +## Testing + +Every seam is an interface, so a `module Test` binds deterministic doubles: + +```x +module Test { + bind SpanExporter -> InMemorySpanExporter as singleton + bind IdGenerator -> FixedIdGenerator // "0000...01", "0000...02", ... + bind TraceClock -> StepClock // advances a fixed step per call + bind Sampler -> AlwaysOnSampler +} + +test "a span records its attributes and nesting" (tracer: Tracer, sink: SpanExporter) { + let root = tracer.start("root", Server) + let child = tracer.start("child", Internal) // parented to root via current + tracer.setAttribute(child, "k", "v") + tracer.end(child) + tracer.end(root) + let spans = (sink as InMemorySpanExporter).spans() + assertEq(spans.len(), 2) + assertEq(spans.get(0).parentSpanId, spans.get(1).context.spanId) // child before root +} +``` + +Deterministic ids and clock make span output byte-stable, so exporter encoding +(including the OTLP/JSON shape) can be asserted exactly. + +## File layout + +``` +std/tracing.xi // umbrella: imports the pieces, re-exports the API +std/tracing/model.xi // SpanContext, SpanKind, StatusCode, SpanData, ... +std/tracing/ports.xi // Tracer, SpanExporter, Sampler, IdGenerator, ... +std/tracing/runtime.xi // DefaultTracingRuntime (Tracer + TracingRuntime) +std/tracing/ids.xi // RandomIdGenerator (crypto.randomHex), SystemTraceClock +std/tracing/sampler.xi // AlwaysOn / AlwaysOff / RatioSampler +std/tracing/propagation.xi // W3C traceparent inject/extract +std/tracing/exporter/console.xi // ConsoleSpanExporter +std/tracing/exporter/memory.xi // InMemorySpanExporter +std/tracing/exporter/otlp.xi // OtlpHttpSpanExporter + OTLP/JSON encoder +std/tracing/config.xi // TracingSettings, TracingConfig +std/tracing/monitoring.xi // TracingMonitoring bridge to std/monitoring +std/tracing/web.xi // server-span helper + semantic conventions +examples/tracing/*.xi // runnable demos +docs/tracing.md // this document +``` + +## Staged delivery + +Each stage is self-contained: interfaces + implementations + `*_test.xi` + +a runnable example + docs, compiled and tested before the next. + +1. **Tracing core**: model, ports, `DefaultTracingRuntime`, `RandomIdGenerator`, + `SystemTraceClock`, `AlwaysOnSampler`, `ConsoleSpanExporter`, + `InMemorySpanExporter`, `enable()`/`flush()`. Manual spans, nesting, attributes, + events, status. Tests + example. +2. **Propagation**: W3C `traceparent` inject/extract, `http.requestWith`, the + incoming-header pattern. Cross-service parenting test. +3. **OTLP exporter**: `OtlpHttpSpanExporter` + OTLP/JSON encoder, config-driven + endpoint. Byte-exact encoder test against a fixed span. +4. **Sampling + config**: `RatioSampler`, `TracingSettings`, batch vs simple + processor. +5. **monitoring bridge + web auto-instrumentation**: `TracingMonitoring`, + `std/tracing/web.xi`, HTTP semantic conventions. + +Then, as separate follow-ups outside `std/tracing`: OTel metric instruments in +`std/monitoring/instruments.xi` and trace-correlated logging. + +## Open questions / limitations + +- **Async context.** The `current` span is process-wide convenience for + synchronous code. Xi `async` runs on worker threads; a span started on the main + path is not implicitly current inside an `async` body. The reliable pattern is + explicit parent passing (`startChild(parentContext, ...)`), which this design + makes first-class. Implicit per-thread context is a later question. +- **Attribute value types.** String-only in v1; the OTLP envelope is already + typed, so bool/int/double/array widen additively. +- **Metric and log signals.** Deliberately out of `std/tracing`; addressed in the + sibling modules noted above so the "tracing" name stays honest. +- **Web catch-all ordering.** Depends on handler ordering guarantees; the explicit + `traced(...)` wrapper is the guaranteed path. diff --git a/examples/language/iflet_else_test.xi b/examples/language/iflet_else_test.xi new file mode 100644 index 0000000..5aae46a --- /dev/null +++ b/examples/language/iflet_else_test.xi @@ -0,0 +1,31 @@ +// Regression: `if let ... { } else { }` must run exactly one branch. A codegen +// bug emitted `else;` followed by a separate unconditional block, so the else +// body ran even when the optional was present (compound-typed optionals hit it). +type Box = { v: Integer } + +mapper boxIf(present: Bool) -> Box? { + if present { return Box { v: 42 } } + return none +} + +mapper firstEven() -> Integer? { return 2 } + +test "if let runs only the then-branch when the value is present (compound)" { + let hit = 0 + if let b = boxIf(true) { hit = b.v } else { hit = 0 - 1 } + assertEq(hit, 42) +} + +test "if let runs only the else-branch when the value is absent (compound)" { + let hit = 0 + if let b = boxIf(false) { hit = b.v } else { hit = 0 - 1 } + assertEq(hit, 0 - 1) +} + +test "if let with else works for a primitive optional too" { + let r = 0 + if let n = firstEven() { r = n } else { r = 0 - 1 } + assertEq(r, 2) +} + +module App {} diff --git a/examples/tracing/basic.xi b/examples/tracing/basic.xi new file mode 100644 index 0000000..788a393 --- /dev/null +++ b/examples/tracing/basic.xi @@ -0,0 +1,35 @@ +// A first trace: a root span with a nested child, attributes, an event, and a +// status. Run it and each finished span prints as one Json line. +// +// xi examples/tracing/basic.xi +// +// The child is parented to the root automatically because the tracer threads a +// current-span stack; nothing is passed by hand. +import "std/tracing.xi" +import "std/log.xi" +import "std/convert.xi" + +async entry (tracer: Tracer as singleton, logger: Logger) main(args: String[]) -> Integer { + tracer.enable() + + let root = tracer.start("checkout", SpanServer) + tracer.setAttribute(root, "user.id", "42") + + let db = tracer.start("db.query", SpanClient) // child of "checkout" + tracer.setAttribute(db, "db.system", "sqlite") + tracer.addEvent(db, "row.fetched") + tracer.setStatus(db, StatusOk, "") + tracer.end(db) + + tracer.addEvent(root, "payment.authorized") + tracer.setStatus(root, StatusOk, "") + tracer.end(root) + + tracer.flush() + logger.info("finished spans: " + int_to_string(tracer.spanCount())) + return 0 +} + +module App { + id = "tracing_basic" +} diff --git a/examples/tracing/logs_test.xi b/examples/tracing/logs_test.xi new file mode 100644 index 0000000..c888fbc --- /dev/null +++ b/examples/tracing/logs_test.xi @@ -0,0 +1,30 @@ +// Trace correlation: the current-context accessors track the active span, and +// correlate() appends ids only while a trace is active. +import "std/tracing/logs.xi" +import "std/tracing/testing.xi" + +test "current trace/span id track the active span" (tracer: Tracer as singleton) { + tracer.enable() + assertEq(tracer.currentTraceId(), "") // nothing active + let s = tracer.start("op", SpanServer) + assertEq(tracer.currentTraceId(), tracer.contextOf(s).traceId) + assertEq(tracer.currentSpanId(), tracer.contextOf(s).spanId) + let child = tracer.start("inner", SpanInternal) + assertEq(tracer.currentSpanId(), tracer.contextOf(child).spanId) // innermost is current + tracer.end(child) + assertEq(tracer.currentSpanId(), tracer.contextOf(s).spanId) // back to the parent + tracer.end(s) + assertEq(tracer.currentTraceId(), "") // nothing active again +} + +test "correlate appends ids only when a trace is active" { + assertEq(correlate("hi", "", ""), "hi") + assertEq(correlate("hi", "abc123", "def456"), "hi trace_id=abc123 span_id=def456") +} + +module App {} + +module Test { + bind SpanExporter -> InMemorySpanExporter + bind SpanSink -> MemorySink as singleton +} diff --git a/examples/tracing/metrics_test.xi b/examples/tracing/metrics_test.xi new file mode 100644 index 0000000..afa5d86 --- /dev/null +++ b/examples/tracing/metrics_test.xi @@ -0,0 +1,38 @@ +// The metric instruments: counter, up-down counter, gauge, histogram, and that +// the snapshot reflects them. +import "std/monitoring/instruments.xi" +import "std/json.xi" + +test "counter, up-down, gauge, and histogram aggregate" (meter: Meter as singleton) { + meter.counterAdd("http.requests", 1) + meter.counterAdd("http.requests", 4) + assertEq(meter.counterValue("http.requests"), 5) + + meter.upDownAdd("queue.depth", 3) + meter.upDownAdd("queue.depth", 0 - 1) + assertEq(meter.upDownValue("queue.depth"), 2) + + meter.gaugeSet("temperature", 20) + meter.gaugeSet("temperature", 25) + assertEq(meter.gaugeValue("temperature"), 25) + + meter.histogramRecord("latency", 10) + meter.histogramRecord("latency", 30) + meter.histogramRecord("latency", 20) + assertEq(meter.histogramCount("latency"), 3) + assertEq(meter.histogramSum("latency"), 60) + assertEq(meter.histogramMin("latency"), 10) + assertEq(meter.histogramMax("latency"), 30) +} + +test "snapshot reports each instrument" (meter: Meter as singleton) { + meter.counterAdd("c", 7) + meter.gaugeSet("g", 9) + meter.histogramRecord("h", 5) + let s = meter.snapshot() + assertEq(json.asNumber(json.get(json.get(s, "counters"), "c")), 7.0) + assertEq(json.asNumber(json.get(json.get(s, "gauges"), "g")), 9.0) + assertEq(json.asNumber(json.get(json.get(json.get(s, "histograms"), "h"), "count")), 1.0) +} + +module App {} diff --git a/examples/tracing/monitoring_bridge_test.xi b/examples/tracing/monitoring_bridge_test.xi new file mode 100644 index 0000000..cf37b87 --- /dev/null +++ b/examples/tracing/monitoring_bridge_test.xi @@ -0,0 +1,23 @@ +// The tracing bridge: the finished-span count and enabled flag surface in the +// std/monitoring report under "tracing". +import "std/tracing/monitoring.xi" +import "std/tracing/testing.xi" +import "std/monitoring.xi" +import "std/json.xi" + +test "span count and enabled flag surface in the monitoring report" (mon: MonitoringRegistry as singleton, tracer: Tracer as singleton) { + mon.enable() + tracer.enable() + tracer.end(tracer.start("a", SpanServer)) + tracer.end(tracer.start("b", SpanClient)) + let r = mon.report() + let t = json.get(r, "tracing") + assertEq(json.asNumber(json.get(t, "spans")), 2.0) +} + +module App {} + +module Test { + bind SpanExporter -> InMemorySpanExporter + bind SpanSink -> MemorySink as singleton +} diff --git a/examples/tracing/otlp_test.xi b/examples/tracing/otlp_test.xi new file mode 100644 index 0000000..b05dc73 --- /dev/null +++ b/examples/tracing/otlp_test.xi @@ -0,0 +1,58 @@ +// OTLP/JSON encoding, asserted structurally (no live collector needed). A span +// is built directly so ids and times are fixed, then the payload shape and the +// OTLP conventions (kind ints, string-encoded times, typed attribute values) are +// checked by navigating the Json. +import "std/tracing.xi" +import "std/tracing/otlp_encode.xi" +import "std/json.xi" + +mapper sampleSpan() -> SpanData { + let attrs = empty List + attrs.push(Attribute { key: "user.id", value: "42" }) + let evs = empty List + evs.push(SpanEvent { name: "cache.miss", timeNanos: 1500, attributes: empty List }) + return SpanData { + context: SpanContext { traceId: "0af7651916cd43dd8448eb211c80319c", spanId: "b7ad6b7169203331", sampled: true }, + parentSpanId: "", name: "checkout", kind: SpanServer, + startNanos: 1000, endNanos: 2000, status: StatusOk, statusMsg: "", + attributes: attrs, events: evs + } +} + +test "otlp payload has the ResourceSpans / ScopeSpans / Span shape" { + let spans = empty List + spans.push(sampleSpan()) + let payload = otlpPayload(spans, "shop") + + let rs = json.at(json.get(payload, "resourceSpans"), 0) + let resAttr = json.at(json.get(json.get(rs, "resource"), "attributes"), 0) + assertEq(json.getString(resAttr, "key"), "service.name") + assertEq(json.getString(json.get(resAttr, "value"), "stringValue"), "shop") + + let ss = json.at(json.get(rs, "scopeSpans"), 0) + assertEq(json.getString(json.get(ss, "scope"), "name"), "std/tracing") + let span0 = json.at(json.get(ss, "spans"), 0) + assertEq(json.getString(span0, "name"), "checkout") + assertEq(json.getString(span0, "traceId"), "0af7651916cd43dd8448eb211c80319c") +} + +test "otlp uses OTLP conventions: kind ints, string times, typed attrs" { + let spans = empty List + spans.push(sampleSpan()) + let span0 = json.at(json.get(json.at(json.get(json.at(json.get(otlpPayload(spans, "shop"), "resourceSpans"), 0), "scopeSpans"), 0), "spans"), 0) + + assertEq(json.getNumber(span0, "kind"), 2.0) // SpanServer -> 2 + assertEq(json.getString(span0, "startTimeUnixNano"), "1000") // 64-bit time as a string + assertEq(json.getString(span0, "endTimeUnixNano"), "2000") + assertEq(json.getNumber(json.get(span0, "status"), "code"), 1.0) // StatusOk -> 1 + + let attr0 = json.at(json.get(span0, "attributes"), 0) + assertEq(json.getString(attr0, "key"), "user.id") + assertEq(json.getString(json.get(attr0, "value"), "stringValue"), "42") + + let ev0 = json.at(json.get(span0, "events"), 0) + assertEq(json.getString(ev0, "name"), "cache.miss") + assertEq(json.getString(ev0, "timeUnixNano"), "1500") +} + +module App {} diff --git a/examples/tracing/pluggable_test.xi b/examples/tracing/pluggable_test.xi new file mode 100644 index 0000000..828b7e2 --- /dev/null +++ b/examples/tracing/pluggable_test.xi @@ -0,0 +1,44 @@ +// Every mechanic is an interface, so a program can replace any of them with a +// bind: the exporter, the sampler, the id generator, the clock, or the whole +// Tracer. These tests bind user-supplied versions and show they take over. +import "std/tracing/testing.xi" + +// A user's sampler that keeps tracing structurally on but emits nothing. +class OffSampler implements Sampler { + deps {} + predicate shouldSample(traceId: String, name: String, kind: SpanKind) -> Bool => false + mapper describe() -> String => "off" +} + +// A user's id generator with fixed ids. +class ConstIds implements IdGenerator { + deps {} + producer newTraceId() -> String => "11111111111111111111111111111111" + producer newSpanId() -> String => "2222222222222222" +} + +test "a bound sampler replaces the default" (tracer: Tracer as singleton, sink: SpanSink as singleton) { + sink.reset() + tracer.enable() + let s = tracer.start("x", SpanServer) + tracer.end(s) + tracer.flush() + assertEq(sink.count(), 0) // OffSampler decided not to record it +} + +test "a bound id generator replaces the default" (tracer: Tracer as singleton) { + tracer.enable() + let s = tracer.start("x", SpanServer) + assertEq(tracer.contextOf(s).traceId, "11111111111111111111111111111111") + assertEq(tracer.contextOf(s).spanId, "2222222222222222") + tracer.end(s) +} + +module App {} + +module Test { + bind SpanExporter -> InMemorySpanExporter + bind SpanSink -> MemorySink as singleton + bind Sampler -> OffSampler // replaces AlwaysOnSampler + bind IdGenerator -> ConstIds // replaces RandomIdGenerator +} diff --git a/examples/tracing/propagation_test.xi b/examples/tracing/propagation_test.xi new file mode 100644 index 0000000..080a2c4 --- /dev/null +++ b/examples/tracing/propagation_test.xi @@ -0,0 +1,55 @@ +// W3C Trace Context: inject/extract round-trips, malformed headers are refused, +// and a remote parent continues the same trace into a local child span. +import "std/tracing/testing.xi" + +test "inject then extract round-trips the context" (prop: Propagator) { + let ctx = SpanContext { traceId: "0af7651916cd43dd8448eb211c80319c", spanId: "b7ad6b7169203331", sampled: true } + let hdr = prop.inject(ctx) + assertEq(hdr, "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + if let got = prop.extract(hdr) { + assertEq(got.traceId, ctx.traceId) + assertEq(got.spanId, ctx.spanId) + assert got.sampled : "sampled flag should survive" + } else { + assert false : "extract should succeed on a valid header" + } +} + +test "the unsampled flag round-trips as 00" (prop: Propagator) { + let ctx = SpanContext { traceId: "0af7651916cd43dd8448eb211c80319c", spanId: "b7ad6b7169203331", sampled: false } + assertEq(prop.inject(ctx), "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00") + if let got = prop.extract(prop.inject(ctx)) { assert not got.sampled : "should be unsampled" } + else { assert false : "extract should succeed" } +} + +test "malformed headers are rejected" (prop: Propagator) { + if let x = prop.extract("") { assert false : "empty must be none" } else { assert true } + if let x = prop.extract("garbage") { assert false : "garbage must be none" } else { assert true } + if let x = prop.extract("00-xyz-b7ad6b7169203331-01") { assert false : "wrong length" } else { assert true } + if let x = prop.extract("00-00000000000000000000000000000000-b7ad6b7169203331-01") { assert false : "zero trace id invalid" } else { assert true } + if let x = prop.extract("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-0z") { assert false : "non-hex flags" } else { assert true } +} + +test "a remote parent continues the trace into a local child" (tracer: Tracer as singleton, prop: Propagator, sink: SpanSink as singleton) { + sink.reset() + tracer.enable() + let remote = SpanContext { traceId: "0af7651916cd43dd8448eb211c80319c", spanId: "b7ad6b7169203331", sampled: true } + if let parent = prop.extract(prop.inject(remote)) { // service A -> header -> service B + let server = tracer.startChild(parent, "GET /orders", SpanServer) + tracer.end(server) + tracer.flush() + let sd = sink.all().get(0) + assertEq(sd.context.traceId, remote.traceId) // same trace across the boundary + assertEq(sd.parentSpanId, remote.spanId) // parented to the remote span + assert sd.context.spanId != remote.spanId : "child has its own span id" + } else { + assert false : "extract should succeed" + } +} + +module App {} + +module Test { + bind SpanExporter -> InMemorySpanExporter + bind SpanSink -> MemorySink as singleton +} diff --git a/examples/tracing/sampling_test.xi b/examples/tracing/sampling_test.xi new file mode 100644 index 0000000..682abe6 --- /dev/null +++ b/examples/tracing/sampling_test.xi @@ -0,0 +1,25 @@ +// The ratio-sampling decision: full/none edges, stability per trace, and that a +// low-prefix trace is kept while a high-prefix one is dropped at 0.5. +import "std/tracing/sampling.xi" + +test "ratio 1.0 keeps everything and 0.0 keeps nothing" { + assert ratioDecision("ffffffffffffffffffffffffffffffff", 1.0) : "1.0 keeps all" + assert not ratioDecision("00000000000000000000000000000000", 0.0) : "0.0 keeps none" +} + +test "the decision is stable for a given trace id" { + let t = "8000abcdef0123456789abcdef012345" + assertEq(ratioDecision(t, 0.5), ratioDecision(t, 0.5)) + assertEq(ratioDecision(t, 0.3), ratioDecision(t, 0.3)) +} + +test "a low-prefix trace is kept and a high-prefix trace dropped at 0.5" { + // first 4 hex -> fraction: 0000 -> 0.0 (< 0.5, kept); ffff -> ~1.0 (>= 0.5, dropped) + assert ratioDecision("0000ffffffffffffffffffffffffffff", 0.5) : "low prefix kept" + assert not ratioDecision("ffffffffffffffffffffffffffffffff", 0.5) : "high prefix dropped" + // 4000 -> 0.25 (< 0.5 kept); c000 -> 0.75 (>= 0.5 dropped) + assert ratioDecision("4000000000000000000000000000000a", 0.5) : "0.25 kept" + assert not ratioDecision("c000000000000000000000000000000a", 0.5) : "0.75 dropped" +} + +module App {} diff --git a/examples/tracing/tracing_test.xi b/examples/tracing/tracing_test.xi new file mode 100644 index 0000000..99b1bbe --- /dev/null +++ b/examples/tracing/tracing_test.xi @@ -0,0 +1,72 @@ +// Stage 1 tracing core, asserted against the in-memory exporter. Deterministic +// without fixed ids: the tests check counts, nesting, attributes, and status. +import "std/tracing/testing.xi" + +test "a finished span is exported after flush" (tracer: Tracer as singleton, sink: SpanSink as singleton) { + sink.reset() + tracer.enable() + let s = tracer.start("op", SpanServer) + tracer.setAttribute(s, "k", "v") + tracer.end(s) + assertEq(sink.count(), 0) // buffered, not yet exported + tracer.flush() + assertEq(sink.count(), 1) + assertEq(tracer.spanCount(), 1) +} + +test "a child span is parented to the current span" (tracer: Tracer as singleton, sink: SpanSink as singleton) { + sink.reset() + tracer.enable() + let root = tracer.start("root", SpanServer) + let child = tracer.start("child", SpanInternal) + tracer.end(child) + tracer.end(root) + tracer.flush() + let spans = sink.all() + assertEq(spans.len(), 2) + // child ends first, so it is exported first; its parent is the root span + let c = spans.get(0) + let r = spans.get(1) + assertEq(c.name, "child") + assertEq(r.name, "root") + assertEq(c.parentSpanId, r.context.spanId) + assertEq(r.parentSpanId, "") // root has no parent + assertEq(c.context.traceId, r.context.traceId) // same trace +} + +test "attributes and events accumulate on the open span" (tracer: Tracer as singleton, sink: SpanSink as singleton) { + sink.reset() + tracer.enable() + let s = tracer.start("work", SpanInternal) + tracer.setAttribute(s, "a", "1") + tracer.setAttribute(s, "b", "2") + tracer.addEvent(s, "step") + tracer.end(s) + tracer.flush() + let sd = sink.all().get(0) + assertEq(sd.attributes.len(), 2) + assertEq(sd.attributes.get(0).key, "a") + assertEq(sd.attributes.get(1).value, "2") + assertEq(sd.events.len(), 1) + assertEq(sd.events.get(0).name, "step") +} + +test "recordError sets Error status and an exception event" (tracer: Tracer as singleton, sink: SpanSink as singleton) { + sink.reset() + tracer.enable() + let s = tracer.start("risky", SpanInternal) + tracer.recordError(s, "boom") + tracer.end(s) + tracer.flush() + let sd = sink.all().get(0) + assertEq(statusCodeNum(sd.status), 2) // Error + assertEq(sd.statusMsg, "boom") + assertEq(sd.events.get(0).name, "exception") +} + +module App {} + +module Test { + bind SpanExporter -> InMemorySpanExporter + bind SpanSink -> MemorySink as singleton +} diff --git a/std/http.xi b/std/http.xi index 8df42fc..b40d36b 100644 --- a/std/http.xi +++ b/std/http.xi @@ -97,6 +97,14 @@ mapper header(resp: Response, name: String) -> String { // Send one request and read the full response (uses Connection: close). producer request(method: String, url: String, body: String, contentType: String) -> Response! { + let noHeaders: String[] = [] + return requestWith(method, url, body, contentType, noHeaders) +} + +// Like request, but with extra request headers (each a "Name: Value" string, no +// CRLF). Use it to carry a traceparent for distributed tracing, an auth token, +// and so on. +producer requestWith(method: String, url: String, body: String, contentType: String, extraHeaders: String[]) -> Response! { let ur = parseUrl(url) if isErr(ur) { return err(ur.err) } let u = ur.value @@ -105,12 +113,22 @@ producer request(method: String, url: String, body: String, contentType: String) + "Host: " + u.host + "\r\n" + "User-Agent: x-http/0.1\r\n" + "Connection: close\r\n" + let h = 0 + while h < extraHeaders.len { + req = req + extraHeaders.data[h] + "\r\n" + h = h + 1 + } if text.length(body) > 0 { req = req + "Content-Type: " + contentType + "\r\n" + "Content-Length: " + text.length(body) + "\r\n" } req = req + "\r\n" + body + return sendHttp(u, req) +} + +// Shared transport: write the raw request and parse the raw response. +producer sendHttp(u: Url, req: String) -> Response! { let raw = "" if u.tls { // HTTPS: the runtime does the TLS round-trip in one call. diff --git a/std/monitoring/instruments.xi b/std/monitoring/instruments.xi new file mode 100644 index 0000000..a0c0eb8 --- /dev/null +++ b/std/monitoring/instruments.xi @@ -0,0 +1,151 @@ +// std/monitoring/instruments — OpenTelemetry-style metric instruments: a Meter +// that owns named counters, up-down counters, gauges, and histograms. This is +// the metrics signal, kept under std/monitoring (its natural home). Bind the +// Meter `as singleton` so its state persists; import this file and the numbers +// also join /monitor/metrics under "metrics". +// +// class Api { deps { meter: Meter } consumer hit() { meter.counterAdd("http.requests", 1) } } +// entry (meter: Meter as singleton) main(args: String[]) { ... } +import "std/monitoring.xi" +import "std/json.xi" + +// One instrument, stored in a flat list (a Map cannot yet be class state). kind: +// 0 counter, 1 up-down, 2 gauge, 3 histogram. value holds the counter/gauge +// number; count/sum/lo/hi hold the histogram aggregate. +type Instrument = { name: String, kind: Integer, value: Integer, count: Integer, sum: Integer, lo: Integer, hi: Integer } + +interface Meter { + consumer counterAdd(name: String, n: Integer) // monotonic total += n + consumer upDownAdd(name: String, n: Integer) // may increase or decrease + consumer gaugeSet(name: String, n: Integer) // last-value gauge + consumer histogramRecord(name: String, n: Integer) // observe a value (count/sum/min/max) + projector counterValue(name: String) -> Integer + projector upDownValue(name: String) -> Integer + projector gaugeValue(name: String) -> Integer + projector histogramCount(name: String) -> Integer + projector histogramSum(name: String) -> Integer + projector histogramMin(name: String) -> Integer + projector histogramMax(name: String) -> Integer + producer snapshot() -> Json +} + +class DefaultMeter implements Meter { + deps {} + state { items: List = empty List } + + // Index of the (name, kind) instrument, or -1. + mapper find(name: String, kind: Integer) -> Integer { + let i = 0 + while i < this.items.len() { + let it = this.items.get(i) + if it.kind == kind and it.name == name { return i } + i = i + 1 + } + return 0 - 1 + } + + // Add to a counter-like instrument (counter or up-down), creating it if new. + consumer accumulate(name: String, kind: Integer, n: Integer) { + let i = find(name, kind) + if i >= 0 { + let it = this.items.get(i) + this.items.set(i, Instrument { name: name, kind: kind, value: it.value + n, count: 0, sum: 0, lo: 0, hi: 0 }) + } else { + this.items.push(Instrument { name: name, kind: kind, value: n, count: 0, sum: 0, lo: 0, hi: 0 }) + } + } + + consumer counterAdd(name: String, n: Integer) { accumulate(name, 0, n) } + consumer upDownAdd(name: String, n: Integer) { accumulate(name, 1, n) } + + consumer gaugeSet(name: String, n: Integer) { + let i = find(name, 2) + if i >= 0 { + this.items.set(i, Instrument { name: name, kind: 2, value: n, count: 0, sum: 0, lo: 0, hi: 0 }) + } else { + this.items.push(Instrument { name: name, kind: 2, value: n, count: 0, sum: 0, lo: 0, hi: 0 }) + } + } + + consumer histogramRecord(name: String, n: Integer) { + let i = find(name, 3) + if i >= 0 { + let it = this.items.get(i) + let lo = it.lo + let hi = it.hi + if n < lo { lo = n } + if n > hi { hi = n } + this.items.set(i, Instrument { name: name, kind: 3, value: 0, count: it.count + 1, sum: it.sum + n, lo: lo, hi: hi }) + } else { + this.items.push(Instrument { name: name, kind: 3, value: 0, count: 1, sum: n, lo: n, hi: n }) + } + } + + projector valueOf(name: String, kind: Integer) -> Integer { + let i = find(name, kind) + if i >= 0 { return this.items.get(i).value } + return 0 + } + projector counterValue(name: String) -> Integer => valueOf(name, 0) + projector upDownValue(name: String) -> Integer => valueOf(name, 1) + projector gaugeValue(name: String) -> Integer => valueOf(name, 2) + + projector histogramCount(name: String) -> Integer { + let i = find(name, 3) + if i >= 0 { return this.items.get(i).count } + return 0 + } + projector histogramSum(name: String) -> Integer { + let i = find(name, 3) + if i >= 0 { return this.items.get(i).sum } + return 0 + } + projector histogramMin(name: String) -> Integer { + let i = find(name, 3) + if i >= 0 { return this.items.get(i).lo } + return 0 + } + projector histogramMax(name: String) -> Integer { + let i = find(name, 3) + if i >= 0 { return this.items.get(i).hi } + return 0 + } + + producer snapshot() -> Json { + let counters = json.object() + let updowns = json.object() + let gauges = json.object() + let hists = json.object() + let i = 0 + while i < this.items.len() { + let it = this.items.get(i) + if it.kind == 0 { counters = json.set(counters, it.name, json.int(it.value)) } + else if it.kind == 1 { updowns = json.set(updowns, it.name, json.int(it.value)) } + else if it.kind == 2 { gauges = json.set(gauges, it.name, json.int(it.value)) } + else { + let ho = json.object() + ho = json.set(ho, "count", json.int(it.count)) + ho = json.set(ho, "sum", json.int(it.sum)) + ho = json.set(ho, "min", json.int(it.lo)) + ho = json.set(ho, "max", json.int(it.hi)) + hists = json.set(hists, it.name, ho) + } + i = i + 1 + } + let o = json.object() + o = json.set(o, "counters", counters) + o = json.set(o, "upDownCounters", updowns) + o = json.set(o, "gauges", gauges) + o = json.set(o, "histograms", hists) + return o + } +} + +// Surface the meter's numbers in the std/monitoring report under "metrics". +class MetricsMonitoring implements Monitoring { + deps { meter: Meter as singleton } + mapper name() -> String => "metrics" + consumer startMonitor() { } + producer healthy() -> Bool => true + producer metrics() -> Json => meter.snapshot() +} diff --git a/std/tracing.xi b/std/tracing.xi new file mode 100644 index 0000000..d182556 --- /dev/null +++ b/std/tracing.xi @@ -0,0 +1,30 @@ +// std/tracing — OpenTelemetry-compatible distributed tracing for Xi. +// +// Import this to get the tracing API and the default wiring: a random id +// generator, a system clock, an always-on sampler, and the console exporter. +// Inject the Tracer `as singleton` and enable it: +// +// import "std/tracing.xi" +// +// async entry (tracer: Tracer as singleton) main(args: String[]) -> Integer { +// tracer.enable() +// let s = tracer.start("checkout", SpanServer) +// tracer.setAttribute(s, "user.id", "42") +// tracer.end(s) +// tracer.flush() +// return 0 +// } +// module App {} +// +// Nothing is live until `enable()` runs. To send spans to a collector instead of +// the console, bind a different SpanExporter (a later module ships the OTLP one). +// See docs/tracing.md for the full design. +import "std/tracing/model.xi" +import "std/tracing/ports.xi" +import "std/tracing/config.xi" +import "std/tracing/encode.xi" +import "std/tracing/ids.xi" +import "std/tracing/sampler.xi" +import "std/tracing/propagation.xi" +import "std/tracing/exporter/console.xi" +import "std/tracing/runtime.xi" diff --git a/std/tracing/config.xi b/std/tracing/config.xi new file mode 100644 index 0000000..a945de1 --- /dev/null +++ b/std/tracing/config.xi @@ -0,0 +1,31 @@ +// std/tracing/config — typed settings so switching console -> collector, or +// changing the sample rate, is configuration rather than a recompile. +// +// The default is code-provided (a local collector, sample everything). Override +// by binding readConfig, or your own TracingConfig: +// +// module App { bind TracingConfig -> readConfig("application.yaml") } +// +// with, in application.yaml: +// +// tracing: +// serviceName: shop +// endpoint: http://otel-collector:4318 +// sampleRatio: 0.1 + +type TracingSettings = { + serviceName: String, // Resource service.name + endpoint: String, // OTLP base URL; the exporter POSTs /v1/traces + sampleRatio: Number // 0.0 .. 1.0, used by the ratio sampler +} + +interface TracingConfig { + mapper tracing() -> TracingSettings +} + +class DefaultTracingConfig implements TracingConfig { + deps {} + mapper tracing() -> TracingSettings { + return TracingSettings { serviceName: "xi-service", endpoint: "http://localhost:4318", sampleRatio: 1.0 } + } +} diff --git a/std/tracing/encode.xi b/std/tracing/encode.xi new file mode 100644 index 0000000..1cc27e9 --- /dev/null +++ b/std/tracing/encode.xi @@ -0,0 +1,43 @@ +// std/tracing/encode — turn a finished span into Json. The console exporter uses +// the flat form here; the OTLP exporter (a later module) reuses the same field +// values in the OpenTelemetry envelope. +import "std/tracing/model.xi" +import "std/json.xi" + +// A finished span as a flat Json object: identity, timing, kind, status, and its +// attributes as a nested object. +producer spanToJson(sd: SpanData) -> Json { + let o = json.object() + o = json.set(o, "name", json.str(sd.name)) + o = json.set(o, "traceId", json.str(sd.context.traceId)) + o = json.set(o, "spanId", json.str(sd.context.spanId)) + o = json.set(o, "parentSpanId", json.str(sd.parentSpanId)) + o = json.set(o, "kind", json.str(kindName(sd.kind))) + o = json.set(o, "startNanos", json.int(sd.startNanos)) + o = json.set(o, "endNanos", json.int(sd.endNanos)) + o = json.set(o, "durationNanos", json.int(sd.endNanos - sd.startNanos)) + o = json.set(o, "status", json.int(statusCodeNum(sd.status))) + if string_len(sd.statusMsg) > 0 { o = json.set(o, "statusMessage", json.str(sd.statusMsg)) } + let a = json.object() + let i = 0 + while i < sd.attributes.len() { + let at = sd.attributes.get(i) + a = json.set(a, at.key, json.str(at.value)) + i = i + 1 + } + o = json.set(o, "attributes", a) + if sd.events.len() > 0 { + let evs = json.array() + let j = 0 + while j < sd.events.len() { + let ev = sd.events.get(j) + let eo = json.object() + eo = json.set(eo, "name", json.str(ev.name)) + eo = json.set(eo, "timeNanos", json.int(ev.timeNanos)) + evs = json.push(evs, eo) + j = j + 1 + } + o = json.set(o, "events", evs) + } + return o +} diff --git a/std/tracing/exporter/console.xi b/std/tracing/exporter/console.xi new file mode 100644 index 0000000..3f54204 --- /dev/null +++ b/std/tracing/exporter/console.xi @@ -0,0 +1,20 @@ +// std/tracing/exporter/console — prints each finished span as one Json line +// through the injected Logger. Zero setup, for local development. It is the only +// exporter the umbrella import pulls in, so a plain app auto-injects it. +import "std/tracing/ports.xi" +import "std/tracing/encode.xi" +import "std/log.xi" +import "std/json.xi" + +class ConsoleSpanExporter implements SpanExporter { + deps { logger: Logger } + producer export(spans: List) -> Bool { + let i = 0 + while i < spans.len() { + logger.info(json.stringify(spanToJson(spans.get(i)))) + i = i + 1 + } + return true + } + consumer shutdown() { } +} diff --git a/std/tracing/exporter/otlp.xi b/std/tracing/exporter/otlp.xi new file mode 100644 index 0000000..b4f8dc8 --- /dev/null +++ b/std/tracing/exporter/otlp.xi @@ -0,0 +1,28 @@ +// std/tracing/exporter/otlp — export spans to an OpenTelemetry collector over +// HTTP (OTLP/JSON). Opt in by importing this file and binding it; a plain app +// keeps the console exporter. +// +// import "std/tracing.xi" +// import "std/tracing/exporter/otlp.xi" +// module App { bind SpanExporter -> OtlpHttpSpanExporter as singleton } +// +// The endpoint and service name come from TracingConfig (std/tracing/config.xi), +// so pointing at a real collector is a config change. +import "std/tracing/ports.xi" +import "std/tracing/otlp_encode.xi" +import "std/tracing/config.xi" +import "std/http.xi" +import "std/json.xi" + +class OtlpHttpSpanExporter implements SpanExporter { + deps { config: TracingConfig } + producer export(spans: List) -> Bool { + if spans.len() == 0 { return true } + let s = config.tracing() + let body = json.stringify(otlpPayload(spans, s.serviceName)) + let resp = http.post(s.endpoint + "/v1/traces", body, "application/json") + if isErr(resp) { return false } + return resp.value.status >= 200 and resp.value.status < 300 + } + consumer shutdown() { } +} diff --git a/std/tracing/ids.xi b/std/tracing/ids.xi new file mode 100644 index 0000000..8193dd7 --- /dev/null +++ b/std/tracing/ids.xi @@ -0,0 +1,18 @@ +// std/tracing/ids — the default id generator and clock. Random ids are drawn +// from crypto and formatted as lowercase hex, exactly the OpenTelemetry shape. +import "std/tracing/ports.xi" +import "std/crypto.xi" +import "std/time.xi" + +// 128-bit trace ids and 64-bit span ids as hex (32 and 16 characters). +class RandomIdGenerator implements IdGenerator { + deps {} + producer newTraceId() -> String => crypto.randomHex(16) + producer newSpanId() -> String => crypto.randomHex(8) +} + +// Wall-clock nanoseconds since the Unix epoch, for span start and end. +class SystemTraceClock implements TraceClock { + deps {} + producer nowNanos() -> Integer => time.nowNanos() +} diff --git a/std/tracing/logs.xi b/std/tracing/logs.xi new file mode 100644 index 0000000..62d86bd --- /dev/null +++ b/std/tracing/logs.xi @@ -0,0 +1,33 @@ +// std/tracing/logs — trace-correlated logging. TraceLog wraps the injected +// Logger and appends the active span's ids, so a log line can be tied back to +// its trace: +// +// class OrderService { +// deps { log: TraceLog } +// consumer place() { log.info("placing order") } // -> "... trace_id= span_id=" +// } +// +// It is a distinct interface (not a Logger replacement) to avoid a decorator +// depending on itself; when no span is active the message is logged unchanged. +import "std/tracing.xi" +import "std/log.xi" +import "std/text.xi" + +// Append trace correlation to a message, but only while a trace is active. +mapper correlate(msg: String, traceId: String, spanId: String) -> String { + if text.length(traceId) == 0 { return msg } + return msg + " trace_id=" + traceId + " span_id=" + spanId +} + +interface TraceLog { + consumer info(msg: String) + consumer warn(msg: String) + consumer error(msg: String) +} + +class DefaultTraceLog implements TraceLog { + deps { logger: Logger, tracer: Tracer as singleton } + consumer info(msg: String) { logger.info(correlate(msg, tracer.currentTraceId(), tracer.currentSpanId())) } + consumer warn(msg: String) { logger.warn(correlate(msg, tracer.currentTraceId(), tracer.currentSpanId())) } + consumer error(msg: String) { logger.error(correlate(msg, tracer.currentTraceId(), tracer.currentSpanId())) } +} diff --git a/std/tracing/model.xi b/std/tracing/model.xi new file mode 100644 index 0000000..da4ff68 --- /dev/null +++ b/std/tracing/model.xi @@ -0,0 +1,69 @@ +// std/tracing/model — the value types of a trace. Data only; the behavior lives +// in the Tracer service. No namespace: the SpanKind variants and record types +// are used directly by application code (`SpanServer`, `tracer.contextOf(span)`). + +// The propagatable identity of a span. traceId is 16 bytes (32 hex chars) and +// spanId is 8 bytes (16 hex chars), matching the OpenTelemetry wire format. +type SpanContext = { + traceId: String, + spanId: String, + sampled: Bool +} + +// OpenTelemetry span kinds and status. The variant names are prefixed so they do +// not collide with a user program's own sum types (variant names are global). +type SpanKind = | SpanInternal | SpanServer | SpanClient | SpanProducer | SpanConsumer +type StatusCode = | StatusUnset | StatusOk | StatusError + +type Attribute = { key: String, value: String } +type SpanEvent = { name: String, timeNanos: Integer, attributes: List } + +// A span in progress or finished. Held by the Tracer, never by user code. The +// attributes/events are Lists so they accumulate in place while the span is open. +type SpanData = { + context: SpanContext, + parentSpanId: String, + name: String, + kind: SpanKind, + startNanos: Integer, + endNanos: Integer, + status: StatusCode, + statusMsg: String, + attributes: List, + events: List +} + +// The lightweight handle user code holds: identity only. Every mutation goes +// through the Tracer, which owns the span's state. +type Span = { context: SpanContext, id: String } + + +// OTLP integer encodings (span.kind, status.code) and a lowercase kind name. +mapper kindCode(k: SpanKind) -> Integer { + match k { + SpanInternal -> { return 1 } + SpanServer -> { return 2 } + SpanClient -> { return 3 } + SpanProducer -> { return 4 } + SpanConsumer -> { return 5 } + } + return 0 +} +mapper statusCodeNum(s: StatusCode) -> Integer { + match s { + StatusUnset -> { return 0 } + StatusOk -> { return 1 } + StatusError -> { return 2 } + } + return 0 +} +mapper kindName(k: SpanKind) -> String { + match k { + SpanInternal -> { return "internal" } + SpanServer -> { return "server" } + SpanClient -> { return "client" } + SpanProducer -> { return "producer" } + SpanConsumer -> { return "consumer" } + } + return "internal" +} diff --git a/std/tracing/monitoring.xi b/std/tracing/monitoring.xi new file mode 100644 index 0000000..2e5c5f7 --- /dev/null +++ b/std/tracing/monitoring.xi @@ -0,0 +1,19 @@ +// std/tracing/monitoring — surface tracing in the std/monitoring report, without +// either module depending on the other's internals. Import it and the span count +// joins /monitor/metrics under "tracing", the same way WebMonitoring adds "web". +import "std/monitoring.xi" +import "std/tracing.xi" +import "std/json.xi" + +class TracingMonitoring implements Monitoring { + deps { tracer: Tracer as singleton } + mapper name() -> String => "tracing" + consumer startMonitor() { } + producer healthy() -> Bool => true + producer metrics() -> Json { + let o = json.object() + o = json.set(o, "spans", json.int(tracer.spanCount())) + o = json.set(o, "enabled", json.of(tracer.isEnabled())) + return o + } +} diff --git a/std/tracing/otlp_encode.xi b/std/tracing/otlp_encode.xi new file mode 100644 index 0000000..d853e02 --- /dev/null +++ b/std/tracing/otlp_encode.xi @@ -0,0 +1,94 @@ +// std/tracing/otlp_encode — SpanData to OTLP/JSON (the OpenTelemetry wire form a +// Collector, Jaeger or Tempo understands). Note two OTLP/JSON rules: 64-bit +// times are encoded as strings, and every attribute value is wrapped in a typed +// envelope ({ "stringValue": ... } here). +import "std/tracing/model.xi" +import "std/json.xi" +import "std/convert.xi" +import "std/text.xi" + +// The instrumentation scope version reported to the collector. +mapper otlpScopeVersion() -> String => "0.1.0" + +// One OTLP attribute: { "key": k, "value": { "stringValue": v } }. +producer otlpAttr(key: String, val: String) -> Json { + let v = json.object() + v = json.set(v, "stringValue", json.str(val)) + let a = json.object() + a = json.set(a, "key", json.str(key)) + a = json.set(a, "value", v) + return a +} + +producer otlpAttrList(attrs: List) -> Json { + let arr = json.array() + let i = 0 + while i < attrs.len() { + let at = attrs.get(i) + arr = json.push(arr, otlpAttr(at.key, at.value)) + i = i + 1 + } + return arr +} + +// One OTLP span object. +producer otlpSpan(sd: SpanData) -> Json { + let o = json.object() + o = json.set(o, "traceId", json.str(sd.context.traceId)) + o = json.set(o, "spanId", json.str(sd.context.spanId)) + if text.length(sd.parentSpanId) > 0 { o = json.set(o, "parentSpanId", json.str(sd.parentSpanId)) } + o = json.set(o, "name", json.str(sd.name)) + o = json.set(o, "kind", json.int(kindCode(sd.kind))) + o = json.set(o, "startTimeUnixNano", json.str(int_to_string(sd.startNanos))) // string per OTLP/JSON + o = json.set(o, "endTimeUnixNano", json.str(int_to_string(sd.endNanos))) + o = json.set(o, "attributes", otlpAttrList(sd.attributes)) + if sd.events.len() > 0 { + let evs = json.array() + let j = 0 + while j < sd.events.len() { + let ev = sd.events.get(j) + let eo = json.object() + eo = json.set(eo, "name", json.str(ev.name)) + eo = json.set(eo, "timeUnixNano", json.str(int_to_string(ev.timeNanos))) + eo = json.set(eo, "attributes", otlpAttrList(ev.attributes)) + evs = json.push(evs, eo) + j = j + 1 + } + o = json.set(o, "events", evs) + } + let st = json.object() + st = json.set(st, "code", json.int(statusCodeNum(sd.status))) + if text.length(sd.statusMsg) > 0 { st = json.set(st, "message", json.str(sd.statusMsg)) } + o = json.set(o, "status", st) + return o +} + +// The full request body: resourceSpans -> scopeSpans -> spans, with the service +// name on the resource. +producer otlpPayload(spans: List, serviceName: String) -> Json { + let resAttrs = json.push(json.array(), otlpAttr("service.name", serviceName)) + let resource = json.set(json.object(), "attributes", resAttrs) + + let scopeObj = json.object() + scopeObj = json.set(scopeObj, "name", json.str("std/tracing")) + scopeObj = json.set(scopeObj, "version", json.str(otlpScopeVersion())) + + let spanArr = json.array() + let i = 0 + while i < spans.len() { + spanArr = json.push(spanArr, otlpSpan(spans.get(i))) + i = i + 1 + } + + let scopeSpans = json.object() + scopeSpans = json.set(scopeSpans, "scope", scopeObj) + scopeSpans = json.set(scopeSpans, "spans", spanArr) + + let resourceSpans = json.object() + resourceSpans = json.set(resourceSpans, "resource", resource) + resourceSpans = json.set(resourceSpans, "scopeSpans", json.push(json.array(), scopeSpans)) + + let root = json.object() + root = json.set(root, "resourceSpans", json.push(json.array(), resourceSpans)) + return root +} diff --git a/std/tracing/ports.xi b/std/tracing/ports.xi new file mode 100644 index 0000000..3cd3b17 --- /dev/null +++ b/std/tracing/ports.xi @@ -0,0 +1,43 @@ +// std/tracing/ports — the seams. Every capability is an interface so it can be +// bound and faked. No namespace: user code implements these directly. +import "std/tracing/model.xi" + +// What application code uses to record work: one span per unit of work. It also +// carries the lifecycle (enable / flush), so a program injects one thing. +interface Tracer { + consumer enable() + predicate isEnabled() -> Bool + producer start(name: String, kind: SpanKind) -> Span // root, or child of the current span + producer startChild(parent: SpanContext, name: String, kind: SpanKind) -> Span + consumer setAttribute(span: Span, key: String, val: String) + consumer addEvent(span: Span, name: String) + consumer setStatus(span: Span, code: StatusCode, message: String) + consumer recordError(span: Span, message: String) // an event plus Error status + mapper contextOf(span: Span) -> SpanContext + consumer end(span: Span) // finish and hand to the exporter + consumer flush() // force export of buffered spans + projector spanCount() -> Integer // spans finished this process + projector currentTraceId() -> String // the active span's trace id, or "" if none + projector currentSpanId() -> String // the active span's id, or "" if none +} + +// Receives finished spans. Adapters: console, in-memory, OTLP over HTTP. +interface SpanExporter { + producer export(spans: List) -> Bool // true on success + consumer shutdown() +} + +// Head-based sampling decision, made once per trace at the root span. +interface Sampler { + predicate shouldSample(traceId: String, name: String, kind: SpanKind) -> Bool + mapper describe() -> String +} + +// Kept separate so tests can bind deterministic ids and time. +interface IdGenerator { + producer newTraceId() -> String + producer newSpanId() -> String +} +interface TraceClock { + producer nowNanos() -> Integer +} diff --git a/std/tracing/propagation.xi b/std/tracing/propagation.xi new file mode 100644 index 0000000..937011f --- /dev/null +++ b/std/tracing/propagation.xi @@ -0,0 +1,69 @@ +// std/tracing/propagation — W3C Trace Context, so a trace continues across +// process boundaries. The traceparent header is fixed-width: +// +// 00-<32 hex traceId>-<16 hex spanId>-<2 hex flags> (55 characters) +// +// version is "00"; the only defined flag bit is "sampled" (0x01). Inject turns a +// SpanContext into the header value; extract parses one back, or none if it is +// absent or malformed. +import "std/tracing/ports.xi" +import "std/tracing/model.xi" +import "std/text.xi" + +interface Propagator { + mapper inject(ctx: SpanContext) -> String + mapper extract(traceparent: String) -> SpanContext? +} + +// Every character is a lowercase-or-uppercase hex digit (and the string is not +// empty). Kept module-local with a w3c prefix to avoid a global name clash. +predicate w3cAllHex(s: String) -> Bool { + let n = text.length(s) + if n == 0 { return false } + let i = 0 + while i < n { + let c = text.charAt(s, i) + let ok = (c >= 48 and c <= 57) or (c >= 97 and c <= 102) or (c >= 65 and c <= 70) + if not ok { return false } + i = i + 1 + } + return true +} + +predicate w3cAllZero(s: String) -> Bool { + let n = text.length(s) + let i = 0 + while i < n { + if text.charAt(s, i) != 48 { return false } // '0' + i = i + 1 + } + return true +} + +class W3CPropagator implements Propagator { + deps {} + + mapper inject(ctx: SpanContext) -> String { + let flags = "00" + if ctx.sampled { flags = "01" } + return "00-" + ctx.traceId + "-" + ctx.spanId + "-" + flags + } + + mapper extract(traceparent: String) -> SpanContext? { + if text.length(traceparent) != 55 { return none } + if text.charAt(traceparent, 2) != 45 { return none } // '-' + if text.charAt(traceparent, 35) != 45 { return none } + if text.charAt(traceparent, 52) != 45 { return none } + let version = text.substring(traceparent, 0, 2) + let traceId = text.substring(traceparent, 3, 35) + let spanId = text.substring(traceparent, 36, 52) + let flags = text.substring(traceparent, 53, 55) + if version != "00" { return none } + if not w3cAllHex(traceId) { return none } + if not w3cAllHex(spanId) { return none } + if not w3cAllHex(flags) { return none } + if w3cAllZero(traceId) { return none } // all-zero id is invalid + if w3cAllZero(spanId) { return none } + return SpanContext { traceId: traceId, spanId: spanId, sampled: flags != "00" } + } +} diff --git a/std/tracing/runtime.xi b/std/tracing/runtime.xi new file mode 100644 index 0000000..8c42351 --- /dev/null +++ b/std/tracing/runtime.xi @@ -0,0 +1,142 @@ +// std/tracing/runtime — the one stateful piece. DefaultTracer owns every open +// span, keeps them in a stack so nested spans parent correctly, and hands +// finished spans to the injected exporter. Bind it `as singleton` (or mark the +// injection `as singleton`) so its state survives across calls. +// +// A user can replace it wholesale: define a class implementing Tracer and +// `bind Tracer -> MyTracer as singleton`. Likewise the exporter, sampler, id +// generator and clock are each an interface, so any one can be swapped alone. +import "std/tracing/ports.xi" +import "std/tracing/model.xi" + +// Export the buffer once it reaches this many finished spans. +mapper batchSize() -> Integer => 512 + +class DefaultTracer implements Tracer { + deps { + exporter: SpanExporter, + sampler: Sampler, + ids: IdGenerator, + clock: TraceClock + } + // open spans as a stack (top = current), the buffer flushed to the exporter, + // and a finished-span count. + state { on: Bool = false, active: List = empty List, finished: List = empty List, total: Integer = 0 } + + consumer enable() { this.on = true } + predicate isEnabled() -> Bool => this.on + projector spanCount() -> Integer => this.total + + projector currentTraceId() -> String { + if this.active.len() > 0 { return this.active.get(this.active.len() - 1).context.traceId } + return "" + } + projector currentSpanId() -> String { + if this.active.len() > 0 { return this.active.get(this.active.len() - 1).context.spanId } + return "" + } + + producer start(name: String, kind: SpanKind) -> Span { + let traceId = "" + let parentId = "" + let sampled = false + let n = this.active.len() + if n > 0 { + let p = this.active.get(n - 1) // parent = current span + traceId = p.context.traceId + parentId = p.context.spanId + sampled = p.context.sampled + } else { + traceId = ids.newTraceId() + sampled = sampler.shouldSample(traceId, name, kind) + } + return begin(traceId, parentId, sampled, name, kind) + } + + producer startChild(parent: SpanContext, name: String, kind: SpanKind) -> Span { + return begin(parent.traceId, parent.spanId, parent.sampled, name, kind) + } + + // Shared span creation: mint the id, push the open span current. + producer begin(traceId: String, parentId: String, sampled: Bool, name: String, kind: SpanKind) -> Span { + let spanId = ids.newSpanId() + let ctx = SpanContext { traceId: traceId, spanId: spanId, sampled: sampled } + let sd = SpanData { + context: ctx, parentSpanId: parentId, name: name, kind: kind, + startNanos: clock.nowNanos(), endNanos: 0, + status: StatusUnset, statusMsg: "", + attributes: empty List, events: empty List + } + this.active.push(sd) + return Span { context: ctx, id: spanId } + } + + // Index of the open span with this id, or -1. The active set is only as deep + // as the current span nesting, so this is a short scan. + mapper findActive(id: String) -> Integer { + let i = 0 + while i < this.active.len() { + if this.active.get(i).context.spanId == id { return i } + i = i + 1 + } + return 0 - 1 + } + + consumer setAttribute(span: Span, key: String, val: String) { + let i = findActive(span.id) + if i >= 0 { this.active.get(i).attributes.push(Attribute { key: key, value: val }) } + } + + consumer addEvent(span: Span, name: String) { + let i = findActive(span.id) + if i >= 0 { + this.active.get(i).events.push(SpanEvent { name: name, timeNanos: clock.nowNanos(), attributes: empty List }) + } + } + + consumer setStatus(span: Span, code: StatusCode, message: String) { + let i = findActive(span.id) + if i >= 0 { + let sd = this.active.get(i) + this.active.set(i, SpanData { + context: sd.context, parentSpanId: sd.parentSpanId, name: sd.name, kind: sd.kind, + startNanos: sd.startNanos, endNanos: sd.endNanos, + status: code, statusMsg: message, + attributes: sd.attributes, events: sd.events + }) + } + } + + consumer recordError(span: Span, message: String) { + addEvent(span, "exception") + setStatus(span, StatusError, message) + } + + mapper contextOf(span: Span) -> SpanContext => span.context + + consumer end(span: Span) { + let i = findActive(span.id) + if i >= 0 { + let sd = this.active.get(i) + let done = SpanData { + context: sd.context, parentSpanId: sd.parentSpanId, name: sd.name, kind: sd.kind, + startNanos: sd.startNanos, endNanos: clock.nowNanos(), + status: sd.status, statusMsg: sd.statusMsg, + attributes: sd.attributes, events: sd.events + } + this.active.removeAt(i) + this.total = this.total + 1 + if done.context.sampled { + this.finished.push(done) + if this.finished.len() >= batchSize() { flush() } + } + } + } + + consumer flush() { + if this.finished.len() > 0 { + exporter.export(this.finished) + this.finished.clear() + } + } +} diff --git a/std/tracing/sampler.xi b/std/tracing/sampler.xi new file mode 100644 index 0000000..bfdb88b --- /dev/null +++ b/std/tracing/sampler.xi @@ -0,0 +1,9 @@ +// std/tracing/sampler — the default sampler. Records every span; ratio-based and +// off samplers arrive with the sampling stage. +import "std/tracing/ports.xi" + +class AlwaysOnSampler implements Sampler { + deps {} + predicate shouldSample(traceId: String, name: String, kind: SpanKind) -> Bool => true + mapper describe() -> String => "always-on" +} diff --git a/std/tracing/sampling.xi b/std/tracing/sampling.xi new file mode 100644 index 0000000..951b81b --- /dev/null +++ b/std/tracing/sampling.xi @@ -0,0 +1,51 @@ +// std/tracing/sampling — ratio and off samplers. Opt in and bind one; the +// umbrella's AlwaysOnSampler stays the default: +// +// import "std/tracing/sampling.xi" +// module App { bind Sampler -> RatioSampler } // ratio from TracingConfig +// +// The decision is head-based (made once at the root) and stable per trace: it is +// derived from the trace id, so every service in a trace agrees, and children +// inherit the root's flag through the propagated context. +import "std/tracing/ports.xi" +import "std/tracing/config.xi" +import "std/text.xi" + +// Value of the first n hex digits of s (used to derive a stable fraction). +mapper hexPrefixVal(s: String, n: Integer) -> Integer { + let v = 0 + let i = 0 + let lim = n + if text.length(s) < lim { lim = text.length(s) } + while i < lim { + let c = text.charAt(s, i) + let d = 0 + if c >= 48 and c <= 57 { d = c - 48 } + else if c >= 97 and c <= 102 { d = c - 97 + 10 } + else if c >= 65 and c <= 70 { d = c - 65 + 10 } + v = v * 16 + d + i = i + 1 + } + return v +} + +// True for roughly `ratio` of trace ids, decided from the id's first 16 bits so +// the choice is stable for a whole trace. Pure, so it is unit-testable directly. +predicate ratioDecision(traceId: String, ratio: Number) -> Bool { + if ratio >= 1.0 { return true } + if ratio <= 0.0 { return false } + let v = hexPrefixVal(traceId, 4) // 0 .. 65535 + return (v / 65536.0) < ratio +} + +class RatioSampler implements Sampler { + deps { config: TracingConfig } + predicate shouldSample(traceId: String, name: String, kind: SpanKind) -> Bool => ratioDecision(traceId, config.tracing().sampleRatio) + mapper describe() -> String => "ratio" +} + +class AlwaysOffSampler implements Sampler { + deps {} + predicate shouldSample(traceId: String, name: String, kind: SpanKind) -> Bool => false + mapper describe() -> String => "always-off" +} diff --git a/std/tracing/testing.xi b/std/tracing/testing.xi new file mode 100644 index 0000000..e937dbc --- /dev/null +++ b/std/tracing/testing.xi @@ -0,0 +1,43 @@ +// std/tracing/testing — an in-memory exporter for asserting on emitted spans. +// Import it only from tests; a normal app never sees it, so the console exporter +// stays the single SpanExporter and auto-injects. +// +// module Test { +// bind SpanExporter -> InMemorySpanExporter +// bind SpanSink -> MemorySink as singleton +// } +// +// test "..." (tracer: Tracer as singleton, sink: SpanSink as singleton) { ... } +import "std/tracing.xi" + +// A shared store the exporter writes to and the test reads from. A single +// interface with a single implementation, so both sides share one singleton. +interface SpanSink { + consumer record(sd: SpanData) + projector count() -> Integer + producer all() -> List + consumer reset() +} + +class MemorySink implements SpanSink { + deps {} + state { spans: List = empty List } + consumer record(sd: SpanData) { this.spans.push(sd) } + projector count() -> Integer => this.spans.len() + producer all() -> List => this.spans + consumer reset() { this.spans.clear() } +} + +// Records every exported span into the shared sink instead of printing it. +class InMemorySpanExporter implements SpanExporter { + deps { sink: SpanSink } + producer export(spans: List) -> Bool { + let i = 0 + while i < spans.len() { + sink.record(spans.get(i)) + i = i + 1 + } + return true + } + consumer shutdown() { } +} diff --git a/std/tracing/web.xi b/std/tracing/web.xi new file mode 100644 index 0000000..5c04a13 --- /dev/null +++ b/std/tracing/web.xi @@ -0,0 +1,53 @@ +// std/tracing/web — a server span per HTTP request. Xi web dispatch is a set of +// where-guarded handlers rather than a middleware chain, so a handler injects a +// WebTracer and brackets its work: +// +// class OrdersController implements WebRequestHandler { +// deps { wt: WebTracer } +// action handle(req: HttpRequest, res: HttpResponse) where web.route(req, "GET", "/orders/:id") { +// let span = wt.begin(req, "GET /orders/:id") +// // ... handle, res.send(...) ... +// wt.finish(span, 200) +// } +// } +// +// begin continues an inbound trace (the traceparent header, if any) and tags the +// span with HTTP semantic-convention attributes; finish records the status. +import "std/tracing.xi" +import "std/tracing/propagation.xi" +import "std/web.xi" +import "std/convert.xi" + +interface WebTracer { + producer begin(req: HttpRequest, name: String) -> Span + consumer finish(span: Span, statusCode: Integer) +} + +class DefaultWebTracer implements WebTracer { + deps { tracer: Tracer as singleton, prop: Propagator } + + producer begin(req: HttpRequest, name: String) -> Span { + let span = spanFor(req.header("traceparent"), name) + tracer.setAttribute(span, "http.request.method", req.method) + tracer.setAttribute(span, "url.path", req.path) + return span + } + + // Continue the caller's trace if the header carries one, else start a root. + producer spanFor(traceparent: String, name: String) -> Span { + if let parent = prop.extract(traceparent) { + return tracer.startChild(parent, name, SpanServer) + } + return tracer.start(name, SpanServer) + } + + consumer finish(span: Span, statusCode: Integer) { + tracer.setAttribute(span, "http.response.status_code", int_to_string(statusCode)) + if statusCode >= 500 { + tracer.setStatus(span, StatusError, "server error") + } else { + tracer.setStatus(span, StatusOk, "") + } + tracer.end(span) + } +}