@crvouga/mockingbird-service-otel 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,15 +1,31 @@
1
1
  # Changelog — @crvouga/mockingbird-service-otel
2
2
 
3
- ## 3.2.0 (2026-10-09)
3
+ ## 3.4.0 (2026-10-12)
4
4
 
5
5
  ### Features
6
6
 
7
- - match Emulate provider coverage ([16b65ce](https://github.com/crvouga/mockingbird/commit/16b65ce791fa216f28ec1521df636f57771d9c5e))
7
+ - unify the emulator control plane ([2068547](https://github.com/crvouga/mockingbird/commit/2068547bedde4e2e2331c1657c1a78120e84428b))
8
8
 
9
9
  ### Dependencies
10
10
 
11
11
  - `@crvouga/mockingbird-service-sqlite`
12
12
 
13
+ ## 3.3.0 (2026-10-10)
14
+
15
+ ### Features
16
+
17
+ - resolve reported emulator coverage gaps ([6edc643](https://github.com/crvouga/mockingbird/commit/6edc6438444853a51aca27849c4c8c0449109ea0))
18
+
19
+ ### Fixes and improvements
20
+
21
+ - make README examples pass consumer smoke ([9f5bf6a](https://github.com/crvouga/mockingbird/commit/9f5bf6a212fffb03b1f70fb67bce7fcb49f08c40))
22
+
23
+ ## 3.2.0 (2026-10-08)
24
+
25
+ ### Features
26
+
27
+ - match Emulate provider coverage ([16b65ce](https://github.com/crvouga/mockingbird/commit/16b65ce791fa216f28ec1521df636f57771d9c5e))
28
+
13
29
  ## 3.1.0 (2026-10-08)
14
30
 
15
31
  ### Features
package/DISCOVERY.md CHANGED
@@ -35,6 +35,7 @@ repository; live parity is an additional oracle check, not a substitute for the
35
35
 
36
36
  - `GET /__admin/health`
37
37
  - `GET /__admin`
38
+ - `GET /__admin/controls`
38
39
  - `GET /__admin/state`
39
40
  - `GET /__admin/requests`
40
41
  - `GET /__admin/metrics`
@@ -45,6 +46,28 @@ For HTTP services, use `x-mockingbird-namespace` (or the documented credential/p
45
46
  parallel tests do not share state. Admin state, journal, metrics and fault-preset endpoints are
46
47
  designed for assertions and diagnosis by consuming test suites.
47
48
 
49
+ ## Admin controls
50
+
51
+ Every HTTP emulator takes the same controls, and each means the same thing at boot
52
+ (`createRuntime({ adminControls })`, `serve --admin-controls`), over `/__admin`, and in a request header:
53
+
54
+ | Control | Admin API | Request header | Interface |
55
+ | --- | --- | --- | --- |
56
+ | Isolate state by key | `GET /__admin/namespaces` | `x-mockingbird-namespace: <key>` | [Namespaces](https://mockingbird.chrisvouga.dev/docs/admin-controls/namespaces) |
57
+ | Reset to boot | `POST /__admin/reset` | `x-mockingbird-reset: true` | [Reset](https://mockingbird.chrisvouga.dev/docs/admin-controls/reset) |
58
+ | Snapshot and restore | `POST /__admin/snapshots`, `POST /__admin/snapshots/<id>/restore` | `x-mockingbird-snapshot: <name>`, `x-mockingbird-restore: <name>` | [Snapshot and restore](https://mockingbird.chrisvouga.dev/docs/admin-controls/snapshot-and-restore) |
59
+ | Move the clock | `POST /__admin/clock` | `x-mockingbird-time: <instant>`, `x-mockingbird-advance: <duration>` | [Clock](https://mockingbird.chrisvouga.dev/docs/admin-controls/clock) |
60
+ | Read or branch from old state | `GET /__admin/timeline` | `x-mockingbird-at: <checkpoint>`, `x-mockingbird-branch: <name>` | [Checkpoints and branches](https://mockingbird.chrisvouga.dev/docs/admin-controls/checkpoints-and-branches) |
61
+ | Inject a fault | `POST /__admin/faults` | `x-mockingbird-fault: <fault>` | [Faults](https://mockingbird.chrisvouga.dev/docs/admin-controls/faults) |
62
+ | Latency and timeouts | `PUT /__admin/latency` | `x-mockingbird-delay: <duration>`, `x-mockingbird-timeout: <duration>` | [Latency and timeouts](https://mockingbird.chrisvouga.dev/docs/admin-controls/latency-and-timeouts) |
63
+ | Replay from a seed | `PUT /__admin/controls` | `x-mockingbird-seed: <seed>` | [Seed and determinism](https://mockingbird.chrisvouga.dev/docs/admin-controls/seed-and-determinism) |
64
+ | See what happened | `GET /__admin/requests`, `GET /__admin/state` | | [Journal, metrics and state](https://mockingbird.chrisvouga.dev/docs/admin-controls/journal-and-state) |
65
+
66
+ `GET /__admin` lists every route and header with a one-line summary, and `GET /__admin/controls`
67
+ is the namespace's current control document. The same seed and the same requests give the same
68
+ bytes. Setting controls as an emulator starts is [Boot](https://mockingbird.chrisvouga.dev/docs/admin-controls/boot); every route and header is in
69
+ the [Reference](https://mockingbird.chrisvouga.dev/docs/admin-controls/reference). The full guide: <https://mockingbird.chrisvouga.dev/docs/admin-controls>.
70
+
48
71
  ## Report a mismatch or missing capability
49
72
 
50
73
  Follow the [agent reporting contract](https://github.com/crvouga/mockingbird/blob/main/docs/REPORTING_ISSUES.md). Include package version,
package/README.md CHANGED
@@ -30,47 +30,58 @@ any base64 `user:password` unless `--search-auth` is set.
30
30
 
31
31
  ```bash
32
32
  npx mockingbird-otel serve --port 8809 --ingest-token "$OTEL_AUTH_TOKEN"
33
+ # a local collector that takes no credentials, with a browser exporter allowed in
34
+ npx mockingbird-otel serve --ingest-auth none --cors-origins http://localhost:5173
35
+ # a hosted OTLP gateway: Authorization: Basic base64(instanceId:token)
36
+ npx mockingbird-otel serve --ingest-basic-auth "$OTLP_INSTANCE_ID:$OTLP_TOKEN"
33
37
  ```
34
38
 
35
39
  ```ts
36
- import { createRuntime } from "@crvouga/mockingbird-service-otel"
37
-
38
- const otel = createRuntime()
39
- const call = (path: string, init: RequestInit) => otel.fetch(new Request(`http://otel.test${path}`, init))
40
-
41
- // What @opentelemetry/exporter-logs-otlp-http sends for one winston/pino event log.
42
- await call("/v1/logs", {
43
- method: "POST",
44
- headers: { "content-type": "application/json", authorization: "Bearer ingest-token" },
45
- body: JSON.stringify({
46
- resourceLogs: [{
47
- resource: { attributes: [{ key: "service.name", value: { stringValue: "backend" } }] },
48
- scopeLogs: [{ logRecords: [{
49
- severityText: "info",
50
- body: { stringValue: "reconcile done" }, // dropped: bodies are never stored
51
- attributes: [
52
- { key: "event", value: { stringValue: "initial_credit_reconcile_completed" } },
53
- { key: "granted", value: { intValue: "1" } },
54
- ],
55
- }] }],
56
- }],
57
- }),
40
+ import { SeverityNumber } from "@opentelemetry/api-logs"
41
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"
42
+ import { resourceFromAttributes } from "@opentelemetry/resources"
43
+ import { LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"
44
+ import { createServer } from "@crvouga/mockingbird-service-otel/server"
45
+
46
+ const server = await createServer()
47
+
48
+ // The OpenTelemetry SDK your application already uses. Only the exporter's URL changes.
49
+ const exporter = new OTLPLogExporter({
50
+ url: `${server.url}/v1/logs`,
51
+ headers: { authorization: "Bearer ingest-token" },
58
52
  })
53
+ const loggerProvider = new LoggerProvider({
54
+ resource: resourceFromAttributes({ "service.name": "backend" }),
55
+ processors: [new SimpleLogRecordProcessor(exporter)],
56
+ })
57
+ loggerProvider.getLogger("backend").emit({
58
+ severityNumber: SeverityNumber.INFO,
59
+ severityText: "info",
60
+ body: "reconcile done", // dropped: bodies are never stored
61
+ attributes: { event: "initial_credit_reconcile_completed", granted: 1 },
62
+ })
63
+ await loggerProvider.forceFlush()
59
64
 
60
- // Long-poll until it lands, then query it the way our O2 clients do.
61
- await call("/__admin/wait", {
65
+ // Test control: long-poll until the event lands.
66
+ await fetch(`${server.url}/__admin/wait`, {
62
67
  method: "POST",
63
68
  headers: { "content-type": "application/json" },
64
69
  body: JSON.stringify({ kind: "log", where: { event: "initial_credit_reconcile_completed" } }),
65
70
  })
66
- const search = await call("/api/30rBqcDevOrg7Hn2KmQ4xW9sLtY/_search", {
71
+
72
+ // Then query it the way our O2 clients do (OpenObserve's search API is plain HTTP).
73
+ const search = await fetch(`${server.url}/api/30rBqcDevOrg7Hn2KmQ4xW9sLtY/_search`, {
67
74
  method: "POST",
68
75
  headers: { "content-type": "application/json", authorization: `Basic ${btoa("agent:pw")}` },
69
76
  body: JSON.stringify({
70
77
  query: { sql: `SELECT * FROM "default" WHERE event = 'initial_credit_reconcile_completed'` },
71
78
  }),
72
79
  })
80
+ console.log(await search.json())
73
81
  // { hits: [{ service_name: "backend", event: "…", granted: 1, severity_text: "info", _timestamp }], … }
82
+
83
+ await loggerProvider.shutdown()
84
+ await server.close()
74
85
  ```
75
86
 
76
87
  ### Receiver
@@ -80,10 +91,61 @@ const search = await call("/api/30rBqcDevOrg7Hn2KmQ4xW9sLtY/_search", {
80
91
  | `POST /v1/traces` | `ExportTraceServiceRequest`, `application/json` or `application/x-protobuf` (optionally `content-encoding: gzip`) → `200 {partialSuccess: {}}` (an empty protobuf response for protobuf requests). |
81
92
  | `POST /v1/logs` | `ExportLogsServiceRequest`, same encodings and answers. |
82
93
  | `POST /v1/metrics` | Accepted and counted (`GET /__admin/otlp-metrics`), never stored. |
94
+ | `OPTIONS /v1/traces`, `/v1/logs`, `/v1/metrics` | A browser exporter's CORS preflight, answered when `cors.allowedOrigins` is set (see *Browser CORS*); otherwise `405`. |
95
+
96
+ Undecodable payloads are 400, other content types 415. Every receiver error is a
97
+ `google.rpc.Status` (`{code, message}`) in the request's encoding: binary protobuf for an
98
+ `application/x-protobuf` request, JSON otherwise. The optional `stream-name` header picks the
99
+ stream (default `default`).
83
100
 
84
- `Authorization: Bearer <token>` is required (401 without); with `--ingest-token` only that token
85
- is accepted. Undecodable payloads are 400, other content types 415. The optional `stream-name`
86
- header picks the stream (default `default`).
101
+ **Collector auth.** The receiver authenticates exports the way the collector it stands in for
102
+ does, so the exporter under test keeps its real headers. The mode is the `ingestAuth` setting
103
+ (`createRuntime({settings})`, `PUT /__admin/settings` per namespace, or `--ingest-auth`):
104
+
105
+ | `ingestAuth` | An export needs | Narrowed by |
106
+ | --- | --- | --- |
107
+ | `bearer` (default) | `Authorization: Bearer <token>`: any token, 401 without one. | `ingestTokens` / `--ingest-token`: only those tokens. |
108
+ | `basic` | `Authorization: Basic base64(user:password)`, the header a hosted OTLP gateway takes (Grafana Cloud: `instanceId:token`): any credentials, 401 without them. | `ingestUsers: [{username, password}]` / `--ingest-basic-auth <user:password>`: only those. |
109
+ | `none` | Nothing: a local collector. An `Authorization` header an exporter still sends is ignored. | — |
110
+
111
+ A refused export is `401 {"code": 16, "message": "Unauthenticated"}`. A mode takes only its own
112
+ scheme (a bearer token is a 401 in `basic` mode and the reverse), credentials are checked before
113
+ the payload is parsed, and in every mode an accepted payload is parsed and stored the same way.
114
+ Only the receiver changes: O2 search always wants Basic credentials.
115
+
116
+ **Browser CORS.** Off by default: `OPTIONS /v1/*` is `405` and no response carries CORS headers.
117
+ Set `cors` (same three places; `--cors-origins`, `--cors-headers`) to answer a browser exporter as
118
+ the OpenTelemetry Collector's `cors:` block does:
119
+
120
+ ```ts
121
+ import { createRuntime } from "@crvouga/mockingbird-service-otel"
122
+
123
+ createRuntime({
124
+ settings: {
125
+ ingestAuth: "none",
126
+ cors: {
127
+ allowedOrigins: ["https://app.example.test", "https://*.preview.example.test"], // or "*"
128
+ allowedHeaders: ["authorization", "content-type", "traceparent", "tracestate"], // or "*"
129
+ allowedMethods: ["POST"], // default GET, POST, HEAD
130
+ exposedHeaders: [], // Access-Control-Expose-Headers on the export
131
+ maxAge: 600, // Access-Control-Max-Age, seconds
132
+ },
133
+ },
134
+ })
135
+ ```
136
+
137
+ A preflight (`OPTIONS` with `Origin` and `Access-Control-Request-Method`) is answered before
138
+ authentication, always `204`. When the origin, the requested method and every name in
139
+ `Access-Control-Request-Headers` are allowed it carries `Access-Control-Allow-Origin` (the
140
+ origin, or `*`), `-Allow-Methods`, `-Allow-Headers` (both echoing the request),
141
+ `-Allow-Credentials: true` and `-Max-Age`; otherwise it carries none of them, which is what
142
+ fails it in the browser. The export's own response, errors included, carries
143
+ `Access-Control-Allow-Origin`, `-Allow-Credentials` and `Vary: Origin` for an allowed origin.
144
+ With `allowedHeaders` unset only `accept`, `content-type` and `x-requested-with` pass; once it
145
+ is set the list is exact, so name `content-type` (an OTLP content type is never CORS-safelisted)
146
+ and the propagation headers your exporter adds. A preflight has no credential to pick a
147
+ namespace by: it reads the default namespace's settings unless the endpoint uses the
148
+ `/__admin/ns/<name>` prefix (settings passed to `createRuntime` apply to every namespace).
87
149
 
88
150
  **Storage, as O2 stores it.** Every field name is lowercased and flattened (`clientUserId` →
89
151
  `clientuserid`, `http.status_code` → `http_status_code`, nested maps joined with `_`); resource
@@ -92,7 +154,8 @@ attributes get a `service_` prefix (`service_service_version`,
92
154
  dropped. Logs carry `_timestamp` (µs), `severity_text`, `severity_number`, `trace_id`, `span_id`
93
155
  and their attributes (`event`, …). Spans carry `operation_name`, `trace_id`, `span_id`,
94
156
  `reference_parent_span_id`, `span_kind`, `span_status` (`UNSET`/`OK`/`ERROR`), `start_time` /
95
- `end_time` (ns), `duration` (µs), `events` (JSON) and their attributes. **Log bodies are dropped**
157
+ `end_time` (ns), `duration` (µs), `events` (JSON: each event's name, time and attributes) and
158
+ their attributes, with ids exactly as exported. **Log bodies are dropped**
96
159
  unless `--keep-bodies` (a body can hold a prompt or PHI; truncation is not protection); the
97
160
  `body` column still exists in the schema so recipes that select it keep working.
98
161
 
@@ -133,18 +196,27 @@ expect. A query on a stream with no data answers empty hits.
133
196
  | `GET /__admin/spans?service=&name=&trace_id=&org=` | Stored span rows. |
134
197
  | `POST /__admin/wait` | `{kind: "log"\|"span", where: {event: "…", …}, count?: 1, timeoutMs?: 5000, org?}`: long-polls until `count` rows match (200 `{matched, count}`) or times out (408 with what matched). `where` keys may be spelt as emitted (`clientUserId`, `service.name`) or as stored. |
135
198
  | `GET /__admin/otlp-metrics` | `{requests, bytes, metrics}` for `/v1/metrics`. |
136
- | `PUT /__admin/settings` | `{ingestTokens?, searchUsers?: [{username, password}], organizations?: [{identifier, name}], routing?: {byEnvironment, default}, keepBodies?}` for the calling namespace. |
199
+ | `GET /__admin/settings` | The calling namespace's settings. |
200
+ | `PUT /__admin/settings` | `{ingestAuth?: "bearer"\|"basic"\|"none", ingestTokens?, ingestUsers?: [{username, password}], cors?: {allowedOrigins, allowedHeaders?, allowedMethods?, exposedHeaders?, maxAge?}, searchUsers?: [{username, password}], organizations?: [{identifier, name}], routing?: {byEnvironment, default}, keepBodies?}` for the calling namespace; only the keys sent change. |
201
+
202
+ `/__admin/spans`, `/__admin/logs` and `/__admin/wait` are the export of what was ingested: one
203
+ normalized row per span or log record, every attribute kept (see *Storage* above). The original
204
+ OTLP request batches are not kept, and `GET /__admin/requests` records each export without its
205
+ body.
137
206
 
138
207
  Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`): `rate_limited` (429 +
139
208
  `retry-after: 1`), `bad_gateway` (502), `unavailable` (503), `gateway_timeout` (504) — all
140
209
  retried by the SDK — `server_error` (500) and `unauthorized` (401), which the SDK drops,
141
210
  `partial_success` (200 rejecting every item, JSON only) and `search_unavailable` (O2 search 503).
211
+ They work in every auth mode and fault the export `POST` only, never a CORS preflight; their
212
+ bodies are JSON whatever the request's encoding.
142
213
 
143
214
  ### Namespaces
144
215
 
145
216
  `x-mockingbird-namespace`, a `/__admin/ns/<name>` prefix on the endpoint and base URL, or by credential:
146
217
  `PUT /__admin/credentials {"credentials": {"<OTEL_AUTH_TOKEN>": "w1", "<O2 username>": "w1"}}`
147
- (map both so a worker's exports and searches meet).
218
+ (map both so a worker's exports and searches meet). In `basic` mode the credential is the Basic
219
+ username (the instance id); in `none` mode there is none, so use the header or the path prefix.
148
220
 
149
221
  ### Deliberately not modelled
150
222
 
@@ -155,6 +227,14 @@ retried by the SDK — `server_error` (500) and `unauthorized` (401), which the
155
227
  - O2 ingestion via `/api/{org}/{stream}/_json` or `/api/{org}/v1/logs`, dashboards, alerts and
156
228
  the UI; the collector's own batching, tail sampling and transform processors.
157
229
  - `partialSuccess` for protobuf requests (an empty success response is sent).
230
+ - The original OTLP batches, and the span fields no row holds: links, trace state, flags, the
231
+ status message, the scope version and the dropped-item counts. `start_time` / `end_time` are
232
+ JSON numbers, so nanoseconds past 2^53 round (`_timestamp` and `duration`, in µs, are exact).
233
+ - A hosted gateway's own surface: its `/otlp` path prefix (point the exporter at the emulator's
234
+ root, or set the per-signal endpoint), its token scopes and the wording of its 401. The 401 is
235
+ the OTLP `Status` above.
236
+ - The Collector's CORS library also refuses a preflight whose `Access-Control-Request-Headers`
237
+ is not the lowercase, sorted list browsers send; here the names match in any order and case.
158
238
  - Log bodies by default (see `--keep-bodies`).
159
239
 
160
240
  ## API
@@ -162,12 +242,12 @@ retried by the SDK — `server_error` (500) and `unauthorized` (401), which the
162
242
  | Export | Kind | Description |
163
243
  | --- | --- | --- |
164
244
  | `OtelAPI` | class | The in-process emulator: `fetch(request)`, `reset()`, `logs()`, `spans()`, `state`. Options: `sqlite`, `now`, `namespace`, `settings`. |
165
- | `createRuntime` | function | The emulator with the full service contract (health, admin, wait, namespaces, credentials, presets). Options: `settings`, `clock`, `seed`, `adminKey`, `onLog`, `sqlite`. |
245
+ | `createRuntime` | function | The emulator with the full service contract (health, admin, wait, namespaces, credentials, presets). Options: `settings` (`ingestAuth`, `ingestTokens`, `ingestUsers`, `cors`, `searchUsers`, `organizations`, `routing`, `keepBodies`), `clock`, `seed`, `adminKey`, `onLog`, `sqlite`. |
166
246
  | `OTEL_PRESETS` | object | Every named fault preset. |
167
247
  | `OTEL_NAMESPACE` | string | The service name, `"otel"`. |
168
- | `otelCredential` | function | The OTLP bearer token or the O2 Basic username (how credentials map to namespaces). |
248
+ | `otelCredential` | function | The OTLP bearer token or the Basic username (how credentials map to namespaces). |
169
249
  | `adminRow` | function | A stored row as the admin routes show it (`_org`, `_stream` plus its columns). |
170
- | `DEFAULT_ORGANIZATIONS`, `DEFAULT_SETTINGS` | values | The seeded orgs and settings. |
250
+ | `DEFAULT_ORGANIZATIONS`, `DEFAULT_SETTINGS`, `INGEST_AUTH_MODES` | values | The seeded orgs and settings (`ingestAuth: "bearer"`, CORS off), and the auth modes. |
171
251
  | `logRows`, `spanRows`, `formatKey` | functions | OTLP/JSON export → O2 rows, and O2's field-name normalisation. |
172
252
  | `decodeLogsRequest`, `decodeTraceRequest`, `ProtobufError` | functions, class | The protobuf decoder: OTLP protobuf → OTLP/JSON shape. |
173
253
  | `parseSql`, `execute`, `referencedColumns`, `SqlError` | functions, class | The SQL subset: parse, run over rows, list the columns a query reads. |
package/SUPPORT.md CHANGED
@@ -2,15 +2,18 @@
2
2
 
3
3
  Generated from `openapi.yaml`; do not edit by hand.
4
4
 
5
- - operations in spec: **7**
6
- - supported by the emulator: **7**
7
- - parity enabled: **7**
5
+ - operations in spec: **10**
6
+ - supported by the emulator: **10**
7
+ - parity enabled: **10**
8
8
 
9
9
  | operationId | route | emulator | parity | notes |
10
10
  | --- | --- | --- | --- | --- |
11
11
  | `ExportTraces` | `POST /v1/traces` | ✅ supported | ⚠️ unsafe (opt-in) | |
12
+ | `PreflightTraces` | `OPTIONS /v1/traces` | ✅ supported | ✅ | |
12
13
  | `ExportLogs` | `POST /v1/logs` | ✅ supported | ⚠️ unsafe (opt-in) | |
14
+ | `PreflightLogs` | `OPTIONS /v1/logs` | ✅ supported | ✅ | |
13
15
  | `ExportMetrics` | `POST /v1/metrics` | ✅ supported | ⚠️ unsafe (opt-in) | |
16
+ | `PreflightMetrics` | `OPTIONS /v1/metrics` | ✅ supported | ✅ | |
14
17
  | `ListOrganizations` | `GET /api/organizations` | ✅ supported | ✅ | |
15
18
  | `ListStreams` | `GET /api/{org}/streams` | ✅ supported | ✅ | |
16
19
  | `GetStreamSchema` | `GET /api/{org}/streams/{stream}/schema` | ✅ supported | ✅ | |