@crvouga/mockingbird-service-otel 3.2.0 → 3.3.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,14 +1,20 @@
1
1
  # Changelog — @crvouga/mockingbird-service-otel
2
2
 
3
- ## 3.2.0 (2026-10-09)
3
+ ## 3.3.0 (2026-10-11)
4
4
 
5
5
  ### Features
6
6
 
7
- - match Emulate provider coverage ([16b65ce](https://github.com/crvouga/mockingbird/commit/16b65ce791fa216f28ec1521df636f57771d9c5e))
7
+ - resolve reported emulator coverage gaps ([6edc643](https://github.com/crvouga/mockingbird/commit/6edc6438444853a51aca27849c4c8c0449109ea0))
8
+
9
+ ### Fixes and improvements
10
+
11
+ - make README examples pass consumer smoke ([9f5bf6a](https://github.com/crvouga/mockingbird/commit/9f5bf6a212fffb03b1f70fb67bce7fcb49f08c40))
8
12
 
9
- ### Dependencies
13
+ ## 3.2.0 (2026-10-08)
10
14
 
11
- - `@crvouga/mockingbird-service-sqlite`
15
+ ### Features
16
+
17
+ - match Emulate provider coverage ([16b65ce](https://github.com/crvouga/mockingbird/commit/16b65ce791fa216f28ec1521df636f57771d9c5e))
12
18
 
13
19
  ## 3.1.0 (2026-10-08)
14
20
 
package/README.md CHANGED
@@ -30,6 +30,10 @@ 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
@@ -80,10 +84,61 @@ const search = await call("/api/30rBqcDevOrg7Hn2KmQ4xW9sLtY/_search", {
80
84
  | `POST /v1/traces` | `ExportTraceServiceRequest`, `application/json` or `application/x-protobuf` (optionally `content-encoding: gzip`) → `200 {partialSuccess: {}}` (an empty protobuf response for protobuf requests). |
81
85
  | `POST /v1/logs` | `ExportLogsServiceRequest`, same encodings and answers. |
82
86
  | `POST /v1/metrics` | Accepted and counted (`GET /__admin/otlp-metrics`), never stored. |
87
+ | `OPTIONS /v1/traces`, `/v1/logs`, `/v1/metrics` | A browser exporter's CORS preflight, answered when `cors.allowedOrigins` is set (see *Browser CORS*); otherwise `405`. |
83
88
 
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`).
89
+ Undecodable payloads are 400, other content types 415. Every receiver error is a
90
+ `google.rpc.Status` (`{code, message}`) in the request's encoding: binary protobuf for an
91
+ `application/x-protobuf` request, JSON otherwise. The optional `stream-name` header picks the
92
+ stream (default `default`).
93
+
94
+ **Collector auth.** The receiver authenticates exports the way the collector it stands in for
95
+ does, so the exporter under test keeps its real headers. The mode is the `ingestAuth` setting
96
+ (`createRuntime({settings})`, `PUT /__admin/settings` per namespace, or `--ingest-auth`):
97
+
98
+ | `ingestAuth` | An export needs | Narrowed by |
99
+ | --- | --- | --- |
100
+ | `bearer` (default) | `Authorization: Bearer <token>`: any token, 401 without one. | `ingestTokens` / `--ingest-token`: only those tokens. |
101
+ | `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. |
102
+ | `none` | Nothing: a local collector. An `Authorization` header an exporter still sends is ignored. | — |
103
+
104
+ A refused export is `401 {"code": 16, "message": "Unauthenticated"}`. A mode takes only its own
105
+ scheme (a bearer token is a 401 in `basic` mode and the reverse), credentials are checked before
106
+ the payload is parsed, and in every mode an accepted payload is parsed and stored the same way.
107
+ Only the receiver changes: O2 search always wants Basic credentials.
108
+
109
+ **Browser CORS.** Off by default: `OPTIONS /v1/*` is `405` and no response carries CORS headers.
110
+ Set `cors` (same three places; `--cors-origins`, `--cors-headers`) to answer a browser exporter as
111
+ the OpenTelemetry Collector's `cors:` block does:
112
+
113
+ ```ts
114
+ import { createRuntime } from "@crvouga/mockingbird-service-otel"
115
+
116
+ createRuntime({
117
+ settings: {
118
+ ingestAuth: "none",
119
+ cors: {
120
+ allowedOrigins: ["https://app.example.test", "https://*.preview.example.test"], // or "*"
121
+ allowedHeaders: ["authorization", "content-type", "traceparent", "tracestate"], // or "*"
122
+ allowedMethods: ["POST"], // default GET, POST, HEAD
123
+ exposedHeaders: [], // Access-Control-Expose-Headers on the export
124
+ maxAge: 600, // Access-Control-Max-Age, seconds
125
+ },
126
+ },
127
+ })
128
+ ```
129
+
130
+ A preflight (`OPTIONS` with `Origin` and `Access-Control-Request-Method`) is answered before
131
+ authentication, always `204`. When the origin, the requested method and every name in
132
+ `Access-Control-Request-Headers` are allowed it carries `Access-Control-Allow-Origin` (the
133
+ origin, or `*`), `-Allow-Methods`, `-Allow-Headers` (both echoing the request),
134
+ `-Allow-Credentials: true` and `-Max-Age`; otherwise it carries none of them, which is what
135
+ fails it in the browser. The export's own response, errors included, carries
136
+ `Access-Control-Allow-Origin`, `-Allow-Credentials` and `Vary: Origin` for an allowed origin.
137
+ With `allowedHeaders` unset only `accept`, `content-type` and `x-requested-with` pass; once it
138
+ is set the list is exact, so name `content-type` (an OTLP content type is never CORS-safelisted)
139
+ and the propagation headers your exporter adds. A preflight has no credential to pick a
140
+ namespace by: it reads the default namespace's settings unless the endpoint uses the
141
+ `/__admin/ns/<name>` prefix (settings passed to `createRuntime` apply to every namespace).
87
142
 
88
143
  **Storage, as O2 stores it.** Every field name is lowercased and flattened (`clientUserId` →
89
144
  `clientuserid`, `http.status_code` → `http_status_code`, nested maps joined with `_`); resource
@@ -92,7 +147,8 @@ attributes get a `service_` prefix (`service_service_version`,
92
147
  dropped. Logs carry `_timestamp` (µs), `severity_text`, `severity_number`, `trace_id`, `span_id`
93
148
  and their attributes (`event`, …). Spans carry `operation_name`, `trace_id`, `span_id`,
94
149
  `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**
150
+ `end_time` (ns), `duration` (µs), `events` (JSON: each event's name, time and attributes) and
151
+ their attributes, with ids exactly as exported. **Log bodies are dropped**
96
152
  unless `--keep-bodies` (a body can hold a prompt or PHI; truncation is not protection); the
97
153
  `body` column still exists in the schema so recipes that select it keep working.
98
154
 
@@ -133,18 +189,27 @@ expect. A query on a stream with no data answers empty hits.
133
189
  | `GET /__admin/spans?service=&name=&trace_id=&org=` | Stored span rows. |
134
190
  | `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
191
  | `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. |
192
+ | `GET /__admin/settings` | The calling namespace's settings. |
193
+ | `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. |
194
+
195
+ `/__admin/spans`, `/__admin/logs` and `/__admin/wait` are the export of what was ingested: one
196
+ normalized row per span or log record, every attribute kept (see *Storage* above). The original
197
+ OTLP request batches are not kept, and `GET /__admin/requests` records each export without its
198
+ body.
137
199
 
138
200
  Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`): `rate_limited` (429 +
139
201
  `retry-after: 1`), `bad_gateway` (502), `unavailable` (503), `gateway_timeout` (504) — all
140
202
  retried by the SDK — `server_error` (500) and `unauthorized` (401), which the SDK drops,
141
203
  `partial_success` (200 rejecting every item, JSON only) and `search_unavailable` (O2 search 503).
204
+ They work in every auth mode and fault the export `POST` only, never a CORS preflight; their
205
+ bodies are JSON whatever the request's encoding.
142
206
 
143
207
  ### Namespaces
144
208
 
145
209
  `x-mockingbird-namespace`, a `/__admin/ns/<name>` prefix on the endpoint and base URL, or by credential:
146
210
  `PUT /__admin/credentials {"credentials": {"<OTEL_AUTH_TOKEN>": "w1", "<O2 username>": "w1"}}`
147
- (map both so a worker's exports and searches meet).
211
+ (map both so a worker's exports and searches meet). In `basic` mode the credential is the Basic
212
+ username (the instance id); in `none` mode there is none, so use the header or the path prefix.
148
213
 
149
214
  ### Deliberately not modelled
150
215
 
@@ -155,6 +220,14 @@ retried by the SDK — `server_error` (500) and `unauthorized` (401), which the
155
220
  - O2 ingestion via `/api/{org}/{stream}/_json` or `/api/{org}/v1/logs`, dashboards, alerts and
156
221
  the UI; the collector's own batching, tail sampling and transform processors.
157
222
  - `partialSuccess` for protobuf requests (an empty success response is sent).
223
+ - The original OTLP batches, and the span fields no row holds: links, trace state, flags, the
224
+ status message, the scope version and the dropped-item counts. `start_time` / `end_time` are
225
+ JSON numbers, so nanoseconds past 2^53 round (`_timestamp` and `duration`, in µs, are exact).
226
+ - A hosted gateway's own surface: its `/otlp` path prefix (point the exporter at the emulator's
227
+ root, or set the per-signal endpoint), its token scopes and the wording of its 401. The 401 is
228
+ the OTLP `Status` above.
229
+ - The Collector's CORS library also refuses a preflight whose `Access-Control-Request-Headers`
230
+ is not the lowercase, sorted list browsers send; here the names match in any order and case.
158
231
  - Log bodies by default (see `--keep-bodies`).
159
232
 
160
233
  ## API
@@ -162,12 +235,12 @@ retried by the SDK — `server_error` (500) and `unauthorized` (401), which the
162
235
  | Export | Kind | Description |
163
236
  | --- | --- | --- |
164
237
  | `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`. |
238
+ | `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
239
  | `OTEL_PRESETS` | object | Every named fault preset. |
167
240
  | `OTEL_NAMESPACE` | string | The service name, `"otel"`. |
168
- | `otelCredential` | function | The OTLP bearer token or the O2 Basic username (how credentials map to namespaces). |
241
+ | `otelCredential` | function | The OTLP bearer token or the Basic username (how credentials map to namespaces). |
169
242
  | `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. |
243
+ | `DEFAULT_ORGANIZATIONS`, `DEFAULT_SETTINGS`, `INGEST_AUTH_MODES` | values | The seeded orgs and settings (`ingestAuth: "bearer"`, CORS off), and the auth modes. |
171
244
  | `logRows`, `spanRows`, `formatKey` | functions | OTLP/JSON export → O2 rows, and O2's field-name normalisation. |
172
245
  | `decodeLogsRequest`, `decodeTraceRequest`, `ProtobufError` | functions, class | The protobuf decoder: OTLP protobuf → OTLP/JSON shape. |
173
246
  | `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 | ✅ | |
@@ -1,9 +1,10 @@
1
1
  import {
2
+ INGEST_AUTH_MODES,
2
3
  createClock,
3
4
  createRuntime,
4
5
  parseDuration,
5
6
  resolveAdminPrefix
6
- } from "./chunk-HRGWNDT2.js";
7
+ } from "./chunk-T4AU5UCQ.js";
7
8
 
8
9
  // ../../adapters/node/dist/cli.js
9
10
  import { readFile as readFile2 } from "node:fs/promises";
@@ -803,15 +804,40 @@ var createServer2 = async (options = {}) => {
803
804
  return { ...listening, runtime };
804
805
  };
805
806
  var text = (value) => typeof value === "string" ? value : void 0;
807
+ var credentials = (auth) => {
808
+ const colon = auth.indexOf(":");
809
+ return colon < 0 ? { username: auth, password: "" } : { username: auth.slice(0, colon), password: auth.slice(colon + 1) };
810
+ };
811
+ var csv = (value) => (value ?? "").split(",").map((item) => item.trim()).filter((item) => item !== "");
806
812
  var serveTarget = {
807
813
  name: "otel",
808
814
  defaultPort: DEFAULT_PORT,
809
815
  options: {
816
+ "ingest-auth": {
817
+ type: "string",
818
+ value: "<bearer|basic|none>",
819
+ description: "What the OTLP receiver asks of an export: a bearer token, Basic credentials, or nothing (an unauthenticated collector); default bearer"
820
+ },
810
821
  "ingest-token": {
811
822
  type: "string",
812
823
  value: "<token>",
813
824
  description: "Only accept this OTLP bearer token (the app's OTEL_AUTH_TOKEN); default any"
814
825
  },
826
+ "ingest-basic-auth": {
827
+ type: "string",
828
+ value: "<user:password>",
829
+ description: "Only accept these OTLP Basic credentials (a hosted gateway's instanceId:token); implies --ingest-auth basic"
830
+ },
831
+ "cors-origins": {
832
+ type: "string",
833
+ value: "<origin,\u2026>",
834
+ description: "Origins a browser exporter may send from (https://app.example, https://*.example, or *); default no CORS"
835
+ },
836
+ "cors-headers": {
837
+ type: "string",
838
+ value: "<header,\u2026>",
839
+ description: "Request headers a preflight may name (or *); default accept, content-type, x-requested-with"
840
+ },
815
841
  "search-auth": {
816
842
  type: "string",
817
843
  value: "<user:password>",
@@ -825,15 +851,25 @@ var serveTarget = {
825
851
  create: (values, common) => {
826
852
  const token = text(values["ingest-token"]);
827
853
  const auth = text(values["search-auth"]);
828
- const colon = auth?.indexOf(":") ?? -1;
854
+ const ingestBasic = text(values["ingest-basic-auth"]);
855
+ const ingestAuth = text(values["ingest-auth"]) ?? (ingestBasic ? "basic" : "bearer");
856
+ if (!INGEST_AUTH_MODES.includes(ingestAuth)) {
857
+ throw new Error(`--ingest-auth must be one of ${INGEST_AUTH_MODES.join(", ")}`);
858
+ }
859
+ const origins = csv(text(values["cors-origins"]));
860
+ const headers = csv(text(values["cors-headers"]));
829
861
  return createRuntime({
830
862
  settings: {
863
+ ingestAuth,
831
864
  ...token ? { ingestTokens: [token] } : {},
832
- ...auth ? {
833
- searchUsers: [
834
- colon < 0 ? { username: auth, password: "" } : { username: auth.slice(0, colon), password: auth.slice(colon + 1) }
835
- ]
865
+ ...ingestBasic ? { ingestUsers: [credentials(ingestBasic)] } : {},
866
+ ...origins.length > 0 ? {
867
+ cors: {
868
+ allowedOrigins: origins,
869
+ ...headers.length > 0 ? { allowedHeaders: headers } : {}
870
+ }
836
871
  } : {},
872
+ ...auth ? { searchUsers: [credentials(auth)] } : {},
837
873
  ...values["keep-bodies"] === true ? { keepBodies: true } : {}
838
874
  },
839
875
  ...common.adminPrefix !== void 0 ? { adminPrefix: common.adminPrefix } : {},
@@ -843,7 +879,7 @@ var serveTarget = {
843
879
  });
844
880
  },
845
881
  banner: () => [
846
- "otlp: OTEL_EXPORTER_OTLP_ENDPOINT=<this> (POST /v1/traces, /v1/logs, /v1/metrics; JSON or protobuf), Authorization: Bearer <OTEL_AUTH_TOKEN>",
882
+ "otlp: OTEL_EXPORTER_OTLP_ENDPOINT=<this> (POST /v1/traces, /v1/logs, /v1/metrics; JSON or protobuf), Authorization: Bearer <OTEL_AUTH_TOKEN> (--ingest-auth basic|none for a Basic or unauthenticated collector)",
847
883
  "o2: O2_BASE_URL=<this>, Basic O2_BASIC_AUTH; orgs development=30rBqcDevOrg7Hn2KmQ4xW9sLtY production=3HSzeProdOrg5Jd8VpN1cR6gTfB",
848
884
  "admin: GET /__admin/logs?event=\u2026, POST /__admin/wait {kind, where, count, timeoutMs}"
849
885
  ]
@@ -856,4 +892,4 @@ export {
856
892
  createServer2 as createServer,
857
893
  serveTarget
858
894
  };
859
- //# sourceMappingURL=chunk-CZKU2KT2.js.map
895
+ //# sourceMappingURL=chunk-JZ6IVZNV.js.map