Skip to content

Commit c1b7c38

Browse files
authored
Merge pull request #13 from prisma/codex/otel-traces-observability
[codex] Add OTel request observability
2 parents ffac199 + c5a16ae commit c1b7c38

65 files changed

Lines changed: 7205 additions & 240 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Current built-ins:
119119
- `evlog`
120120
- `generic`
121121
- `metrics`
122+
- `otel-traces`
122123
- `state-protocol`
123124

124125
Planned next built-ins:
@@ -189,6 +190,25 @@ It means:
189190
The internal `__stream_metrics__` stream is created with this profile
190191
automatically.
191192

193+
### `otel-traces`
194+
195+
`otel-traces` is the built-in profile for OpenTelemetry trace spans.
196+
197+
It means:
198+
199+
- the stream content type must be `application/json`
200+
- JSON appends are normalized into the canonical span envelope
201+
- OTLP trace exports are accepted through `POST /v1/traces` and
202+
`POST /v1/stream/{name}/_otlp/v1/traces`
203+
- installing the profile also installs the canonical trace schema/search and
204+
default rollups
205+
- the canonical routing key is `traceId`
206+
207+
See [docs/profile-otel-traces.md](./docs/profile-otel-traces.md) for the
208+
profile and OTLP receiver contract, and
209+
[docs/request-observability.md](./docs/request-observability.md) for
210+
cross-stream lookup over `evlog` events and `otel-traces` spans.
211+
192212
## Profile Versus Schema
193213

194214
What belongs in a profile:
@@ -407,11 +427,14 @@ Not implemented today:
407427

408428
The supported behavior is:
409429

410-
- use `/_profile` to choose `generic`, `state-protocol`, or `evlog`
430+
- use `/_profile` to choose a built-in profile, including `generic`, `evlog`,
431+
`metrics`, `otel-traces`, and `state-protocol`
411432
- use `/_schema` only for schema validation, routing-key config, and schema
412433
evolution
413434
- use `/touch/*` only on `state-protocol` streams with touch enabled
414435
- use normal JSON appends on `evlog` streams to store canonical evlog events
436+
- use OTLP trace endpoints only on `otel-traces` streams, or on `/v1/traces`
437+
when `DS_OTLP_TRACES_STREAM` is configured
415438

416439
Legacy compatibility branches are intentionally not part of the supported
417440
surface.

docs/architecture.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,17 @@ Implemented built-ins today:
3232
- `evlog`
3333
- `generic`
3434
- `metrics`
35+
- `otel-traces`
3536
- `state-protocol`
3637

3738
`generic` adds no canonical payload envelope and leaves schema management to the
3839
user. `evlog` owns canonical wide-event normalization, redaction, and its
3940
default schema/search/rollup registry on JSON append. `metrics` owns canonical
4041
metrics interval normalization, its default schema/search/rollup registry, and
41-
the metrics-block companion family. `state-protocol` owns the live `/touch/*`
42-
surface and its touch configuration.
42+
the metrics-block companion family. `otel-traces` owns canonical span
43+
normalization, OTLP trace export decoding, redaction, backend-side trace limits,
44+
and its default schema/search/rollup registry. `state-protocol` owns the live
45+
`/touch/*` surface and its touch configuration.
4346

4447
See [stream-profiles.md](./stream-profiles.md) for the normative model.
4548

@@ -51,6 +54,8 @@ See [stream-profiles.md](./stream-profiles.md) for the normative model.
5154
- Implements long-poll reads without busy loops.
5255
- Resolves the stream profile definition before handling profile-owned
5356
metadata or routes.
57+
- Uses profile capabilities for OTLP trace ingestion and correlation timeline
58+
conversion instead of hard-coding profile branches in the core route path.
5459
- Admits ingest, read, and search work through bounded in-process concurrency
5560
gates instead of a direct memory-based reject path.
5661

@@ -151,6 +156,20 @@ Today, `metrics` uses the same model to own:
151156
- bundled per-segment `PSCIX2` `.cix` search companions for metrics-serving
152157
state
153158

159+
Today, `otel-traces` uses the same model to own:
160+
161+
- canonical OpenTelemetry span normalization on JSON append
162+
- OTLP JSON/protobuf trace export decoding on `POST /v1/traces` and
163+
`POST /v1/stream/{name}/_otlp/v1/traces`
164+
- pre-append redaction and backend-side attribute/event/link limits
165+
- routing-key defaults from `traceId`
166+
- default schema-owned `search` and `search.rollups` installation
167+
168+
The cross-stream request observability API is a query layer over `evlog` and
169+
`otel-traces` streams. It uses stream search results and profile correlation
170+
capabilities to build summaries, trace trees, service edges, and timelines; it
171+
does not create a separate mutable observability store.
172+
154173
## Control-Plane Metadata
155174

156175
Per stream, SQLite stores:

docs/durable-streams-spec.md

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,19 @@ implementation.
5959
- `GET /v1/stream/{name}/_routing_keys` list routing keys alphabetically
6060
- `GET /v1/stream/{name}/_index_status` get per-stream index status
6161
- `GET /v1/stream/{name}/_details` get combined stream details
62+
- `POST /v1/stream/{name}/_otlp/v1/traces` ingest OTLP traces into an
63+
`otel-traces` stream
6264

63-
### 2.5 Streams collection
65+
### 2.5 Observability resources
66+
67+
- `POST /v1/traces` ingest OTLP traces into `DS_OTLP_TRACES_STREAM`
68+
- `POST /v1/observe/request` correlate request events and trace spans
69+
70+
### 2.6 Streams collection
6471

6572
- `GET /v1/streams` list streams
6673

67-
### 2.6 Server inspection
74+
### 2.7 Server inspection
6875

6976
- `GET /v1/server/_details` get server-scoped configured limits and live runtime state
7077

@@ -211,6 +218,132 @@ Optional fields:
211218
- `group_by`
212219
- `measures`
213220

221+
### 4.4 OTLP trace ingestion
222+
223+
`POST /v1/traces` accepts OTLP trace export requests and writes accepted spans
224+
to the stream named by `DS_OTLP_TRACES_STREAM`.
225+
226+
Rules:
227+
228+
- `DS_OTLP_TRACES_STREAM` must be configured, otherwise the endpoint returns
229+
`400`.
230+
- If the target stream does not exist and `DS_OTLP_AUTO_CREATE=true`, the
231+
server creates an `application/json` stream, installs the `otel-traces`
232+
profile, uploads the profile-owned schema registry, publishes a manifest, and
233+
then appends accepted spans.
234+
- If the target stream does not exist and auto-create is not enabled, the
235+
endpoint returns `404`.
236+
- The target stream must have the `otel-traces` profile.
237+
238+
`POST /v1/stream/{name}/_otlp/v1/traces` accepts the same OTLP payloads for an
239+
explicit stream. The stream must already exist and have the `otel-traces`
240+
profile.
241+
242+
Supported request content types:
243+
244+
- `application/x-protobuf`
245+
- `application/json`
246+
247+
Supported content encodings:
248+
249+
- no encoding / `identity`
250+
- `gzip`
251+
252+
Successful full acceptance returns HTTP `200` with an empty OTLP
253+
`ExportTraceServiceResponse` for protobuf or `{}` for JSON. Partial acceptance
254+
also returns HTTP `200` and includes OTLP partial-success information with the
255+
number of rejected spans and an error message. Clients must not retry spans
256+
rejected by a partial-success response.
257+
258+
Malformed payloads and requests that exceed resource-span or scope-span limits
259+
return `400`. Compressed or decoded OTLP bodies that exceed the configured byte
260+
limits return `413`. Unsupported content types or content encodings return
261+
`415`. If a decodable request exceeds the configured span-count limit, the
262+
server accepts the first spans up to the limit and returns HTTP `200` with OTLP
263+
partial-success information for the rejected overflow. Accepted spans are
264+
appended as canonical JSON span records using `traceId` as the routing key.
265+
266+
### 4.5 Request observability
267+
268+
`POST /v1/observe/request` correlates an event stream and a trace stream at
269+
query time. It does not append data and does not create a new stream profile.
270+
271+
Request body:
272+
273+
```json
274+
{
275+
"streams": {
276+
"events": "app-events",
277+
"traces": "app-traces"
278+
},
279+
"lookup": {
280+
"requestId": "req_123"
281+
},
282+
"time": {
283+
"from": "2026-03-27T00:00:00.000Z",
284+
"to": "2026-03-28T00:00:00.000Z",
285+
"paddingMs": 5000
286+
},
287+
"include": {
288+
"events": true,
289+
"trace": true,
290+
"timeline": true,
291+
"raw": false
292+
},
293+
"limits": {
294+
"events": 100,
295+
"spans": 5000
296+
}
297+
}
298+
```
299+
300+
`lookup` must contain exactly one of `requestId`, `traceId`, or `spanId`.
301+
`streams.events` is required when `include.events=true`; `streams.traces` is
302+
required when `include.trace=true`.
303+
The supported request-observability pairing is `streams.events` with profile
304+
`evlog` and `streams.traces` with profile `otel-traces`.
305+
306+
The endpoint uses the configured `_search` registries for the referenced
307+
streams. Event and trace streams must expose the profile correlation capability.
308+
`include.raw` defaults to `false`. With `raw=false`, `evlog.primary`,
309+
`evlog.matches[].source`, and `trace.spans[]` contain compact normalized records
310+
that keep IDs, timestamps, service/request fields, status/error fields, and
311+
safe request/operation summaries while omitting raw context, attributes,
312+
resources, span events, links, statements, URLs, stack traces, redaction
313+
metadata, and identity internals. Timeline items omit `data`. With `raw=true`,
314+
those response fields include the full profile-normalized source records and
315+
timeline source data.
316+
The response contains:
317+
318+
- `lookup`
319+
- `summary`
320+
- `evlog`
321+
- `trace`
322+
- `timeline`
323+
- `coverage`
324+
325+
The trace response deduplicates returned spans by `traceId:spanId` for the
326+
tree, service map, errors, and critical path. Duplicate span records remain in
327+
the underlying append-only stream.
328+
329+
`trace.rootSpanId` is selected from all returned root candidates by preferring
330+
likely request roots: no parent, server kind, HTTP fields, request ID, and then
331+
duration. Other roots remain in `trace.tree`. `trace.criticalPath` is a
332+
best-effort interval-aware latency path from the selected root when one exists.
333+
334+
`coverage.events` and `coverage.traces` de-duplicate `hits`, `unique_hits`, and
335+
`total.value` by stream and offset across overlapping lookup searches.
336+
`query_count` and `batch_count` report the number of underlying `_search`
337+
batches used. `total.relation` is `gte` when limits, timeouts, incomplete
338+
coverage, or underlying lower-bound totals prevent an exact unique total. Each
339+
coverage object also includes `queries`, preserving per-query diagnostics such
340+
as `q`, returned `hits`, backend `total`, page count, timeout state, and limit
341+
state.
342+
343+
The endpoint returns `400` for invalid request bodies, unsupported profile
344+
combinations, or streams without search configuration. Missing streams return
345+
`404`.
346+
214347
---
215348

216349
## 5. Offsets

docs/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@ and tooling.
2525
- [stream-profiles.md](./stream-profiles.md) — stream/profile/schema model and profile subresource
2626
- [profile-generic.md](./profile-generic.md) — reference for the baseline `generic` profile
2727
- [profile-metrics.md](./profile-metrics.md) — reference for the built-in `metrics` profile
28+
- [profile-otel-traces.md](./profile-otel-traces.md) — reference for the built-in
29+
`otel-traces` profile and OTLP trace ingestion
2830
- [profile-state-protocol.md](./profile-state-protocol.md) — reference for the `state-protocol` profile
2931
- [profile-evlog.md](./profile-evlog.md) — design and reference for the `evlog` profile
32+
- [request-observability.md](./request-observability.md) — cross-stream request
33+
lookup that correlates `evlog` events and `otel-traces` spans
3034
- [schemas.md](./schemas.md) — schema registry and lens behavior
3135
- [durable-lens-v1-schema.md](./durable-lens-v1-schema.md) — reference schema for `durable.lens/v1`
3236
- [sqlite-schema.md](./sqlite-schema.md) — SQLite schema, invariants, and migration expectations

docs/overview.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@ Every stream has a profile.
1515
validation.
1616
- `metrics` is the built-in metrics profile for canonical interval summaries,
1717
default search/rollups, and object-store-native metrics companions.
18+
- `otel-traces` is the built-in OpenTelemetry trace profile for one canonical
19+
JSON span per record, OTLP trace ingestion, trace search/rollups, and request
20+
correlation with `evlog`.
1821
- `state-protocol` is the built-in live/touch profile for JSON State Protocol
1922
streams.
2023
- Profiles define stream semantics; schemas define payload shape.
2124

2225
See [stream-profiles.md](./stream-profiles.md).
26+
See [profile-otel-traces.md](./profile-otel-traces.md) and
27+
[request-observability.md](./request-observability.md) for trace ingestion and
28+
cross-stream request lookup. UIs should use the explicit
29+
`observability.request` descriptor from `GET /v1/streams` or
30+
`GET /v1/stream/{name}/_details` to pair `evlog` and `otel-traces` streams.
2331

2432
This repository currently contains two server modes:
2533

@@ -180,6 +188,13 @@ Optional flags:
180188
- `--bootstrap-from-r2`
181189
- `--auto-tune[=MB]`
182190

191+
Optional OTLP trace receiver configuration:
192+
193+
- `DS_OTLP_TRACES_STREAM=<stream>` enables the default `POST /v1/traces`
194+
receiver target
195+
- `DS_OTLP_AUTO_CREATE=true` lets `/v1/traces` create and profile that stream
196+
as `otel-traces` before accepting spans
197+
183198
### Object Store Configuration
184199

185200
Local MockR2:

docs/profile-evlog.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ The v1 profile:
1919
- support request-centric lookup through the existing routing-key path
2020

2121
The v1 profile does not introduce a separate observability storage engine.
22+
It also does not store OpenTelemetry span graphs; spans belong in
23+
[`otel-traces`](./profile-otel-traces.md) streams and are correlated at query
24+
time.
2225

2326
## Stream Contract
2427

@@ -31,8 +34,58 @@ The v1 profile does not introduce a separate observability storage engine.
3134
default `search` and `search.rollups` config
3235
- the profile provides a default routing key from `requestId`, with `traceId`
3336
fallback
37+
- optional correlation settings can define request/trace field aliases and
38+
`traceparent` parsing for better joins with `otel-traces`
3439
- reads continue to use the normal durable stream APIs
3540

41+
Supported profile shape:
42+
43+
```json
44+
{
45+
"kind": "evlog",
46+
"redactKeys": ["sessiontoken"],
47+
"correlation": {
48+
"requestIdFields": ["requestId", "context.requestId"],
49+
"traceContextFields": [
50+
"traceId",
51+
"spanId",
52+
"traceContext.traceId",
53+
"traceContext.spanId"
54+
],
55+
"parseTraceparent": true
56+
},
57+
"observability": {
58+
"request": {
59+
"tracesStream": "app-traces"
60+
}
61+
}
62+
}
63+
```
64+
65+
`correlation` only affects how the evlog canonical envelope derives
66+
`requestId`, `traceId`, and `spanId`; it does not make evlog accept spans.
67+
When `parseTraceparent` is not false, the profile reads W3C `traceparent` from
68+
`traceparent`, `traceContext.traceparent`, `context.traceparent`, or
69+
`headers.traceparent` if explicit trace fields are absent.
70+
71+
`observability.request.tracesStream` declares the explicit `otel-traces`
72+
counterpart for request-observability clients. When it is present,
73+
`GET /v1/streams` and `GET /v1/stream/{name}/_details` expose:
74+
75+
```json
76+
{
77+
"observability": {
78+
"request": {
79+
"events_stream": "app-events",
80+
"traces_stream": "app-traces"
81+
}
82+
}
83+
}
84+
```
85+
86+
Clients must use this descriptor instead of guessing the trace stream from
87+
other stream names or profiles.
88+
3689
## Canonical Envelope
3790

3891
Each stored event should use this stable top-level shape:
@@ -196,6 +249,7 @@ Current evlog query surfaces:
196249
- `POST /v1/stream/{name}/_search`
197250
- `GET /v1/stream/{name}/_search?q=...`
198251
- `POST /v1/stream/{name}/_aggregate`
252+
- `POST /v1/observe/request` when paired with an `otel-traces` stream
199253

200254
## UI Integration
201255

@@ -206,7 +260,9 @@ record/detail surface.
206260
Recommended integration flow:
207261

208262
1. Create the stream with `application/json`.
209-
2. Install the `evlog` profile with `POST /v1/stream/{name}/_profile`.
263+
2. Install the `evlog` profile with `POST /v1/stream/{name}/_profile`. Include
264+
`observability.request.tracesStream` when this stream has a known
265+
`otel-traces` counterpart.
210266
3. Read `GET /v1/stream/{name}/_details` when the UI needs the combined
211267
stream/profile/schema/index descriptor.
212268
4. Read `GET /v1/stream/{name}/_index_status` for dedicated indexing progress

0 commit comments

Comments
 (0)