OpenTelemetry

@crvouga/mockingbird-service-otel

Stateful mock of an OTLP/HTTP collector (JSON and protobuf traces and logs) and the OpenObserve search API over the same store.

Work in progress. Usable, but incomplete: operations, response shapes and options can still change between releases. Pin an exact version. Check the README's "Deliberately not modelled" notes before relying on it.
Work in progressObservability Runs in the browser7 of 7 operationsIn-process fetchNode servermockingbird-otel
$npm install -D @crvouga/mockingbird-service-otel

Playground

Requests go to the real mock running in this tab. State persists across requests, and the journal shows what it received. A green dot marks operations whose sample succeeds as-is; others need ids from earlier responses.

Loads the real @crvouga/mockingbird-service-otel into this tab on first send
https://otel.gogeviti.com

Send a request to see the mock's response.

Operations

7 of 7 operations in the vendored contract are mocked. OTLP/HTTP per opentelemetry-proto v1 (https://opentelemetry.io/docs/specs/otlp/); the O2 routes follow OpenObserve's API (/api-doc/openapi.json, v0.92) trimmed to what our clients call.

MethodPathOperationStatus
POST/v1/tracesExportTracesOTLP/HTTP trace export (ExportTraceServiceRequest)
POST/v1/logsExportLogsOTLP/HTTP log export (ExportLogsServiceRequest)
POST/v1/metricsExportMetricsOTLP/HTTP metrics export: accepted and counted, never stored
GET/api/organizationsListOrganizationsO2: the organizations the Basic user belongs to (display name β†’ identifier)
GET/api/{org}/streamsListStreamsO2: the org's streams of one type
GET/api/{org}/streams/default/schemaGetStreamSchemaO2: a stream's schema (`schema[].name` are the queryable, lowercased, flattened fields)
POST/api/{org}/_searchSearchO2: SQL search over a stream

Documentation

The package README, the same file that ships in the npm tarball.Edit on GitHub

Stateful mock of an OTLP/HTTP collector in front of the OpenObserve (O2) search API, over one store. Local and E2E runs stop exporting to production telemetry infrastructure, and a test can assert on structured events: emit through the real OpenTelemetry SDK, then read the log back with the same SQL our ops feed and investigation agent send ("the reconcile cron emitted initial_credit_reconcile_completed with granted=1").

  • Operation coverage: SUPPORT.md
  • OTLP/HTTP follows opentelemetry-proto v1; the O2 routes follow OpenObserve's API, trimmed to what our clients call.

Install

Shell
npm install -D @crvouga/mockingbird-service-otel

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies, no protobuf library (a minimal wire-format decoder is built in). Serve it with npx mockingbird-otel serve, createServer from ./server (Node), or createRuntime with any Fetch server.

Usage

Point OTEL_EXPORTER_OTLP_ENDPOINT at the mock and set OTEL_TRACES_SAMPLER_ARG=1 (our SDK samples 10% of root spans otherwise). Point O2_BASE_URL at the same mock; O2_BASIC_AUTH is any base64 user:password unless --search-auth is set.

Shell
npx mockingbird-otel serve --port 8809 --ingest-token "$OTEL_AUTH_TOKEN"
TypeScript
import { createRuntime } from "@crvouga/mockingbird-service-otel"

const otel = createRuntime()
const call = (path: string, init: RequestInit) => otel.fetch(new Request(`http://otel.test${path}`, init))

// What @opentelemetry/exporter-logs-otlp-http sends for one winston/pino event log.
await call("/v1/logs", {
  method: "POST",
  headers: { "content-type": "application/json", authorization: "Bearer ingest-token" },
  body: JSON.stringify({
    resourceLogs: [{
      resource: { attributes: [{ key: "service.name", value: { stringValue: "backend" } }] },
      scopeLogs: [{ logRecords: [{
        severityText: "info",
        body: { stringValue: "reconcile done" }, // dropped: bodies are never stored
        attributes: [
          { key: "event", value: { stringValue: "initial_credit_reconcile_completed" } },
          { key: "granted", value: { intValue: "1" } },
        ],
      }] }],
    }],
  }),
})

// Long-poll until it lands, then query it the way our O2 clients do.
await call("/__admin/wait", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ kind: "log", where: { event: "initial_credit_reconcile_completed" } }),
})
const search = await call("/api/30rBqcDevOrg7Hn2KmQ4xW9sLtY/_search", {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Basic ${btoa("agent:pw")}` },
  body: JSON.stringify({
    query: { sql: `SELECT * FROM "default" WHERE event = 'initial_credit_reconcile_completed'` },
  }),
})
// { hits: [{ service_name: "backend", event: "…", granted: 1, severity_text: "info", _timestamp }], … }

Receiver

Route Behaviour
POST /v1/traces ExportTraceServiceRequest, application/json or application/x-protobuf (optionally content-encoding: gzip) β†’ 200 {partialSuccess: {}} (an empty protobuf response for protobuf requests).
POST /v1/logs ExportLogsServiceRequest, same encodings and answers.
POST /v1/metrics Accepted and counted (GET /__admin/otlp-metrics), never stored.

Authorization: Bearer <token> is required (401 without); with --ingest-token only that token is accepted. Undecodable payloads are 400, other content types 415. The optional stream-name header picks the stream (default default).

Storage, as O2 stores it. Every field name is lowercased and flattened (clientUserId β†’ clientuserid, http.status_code β†’ http_status_code, nested maps joined with _); resource attributes get a service_ prefix (service_service_version, service_deployment_environment_name) except service.name β†’ service_name; null fields are dropped. Logs carry _timestamp (Β΅s), severity_text, severity_number, trace_id, span_id and their attributes (event, …). Spans carry operation_name, trace_id, span_id, reference_parent_span_id, span_kind, span_status (UNSET/OK/ERROR), start_time / end_time (ns), duration (Β΅s), events (JSON) and their attributes. Log bodies are dropped unless --keep-bodies (a body can hold a prompt or PHI; truncation is not protection); the body column still exists in the schema so recipes that select it keep working.

Org routing. deployment.environment.name (or deployment.environment) production lands in the production org; anything else in development, like the collector's dev-org default. Default orgs: default, development (30rBqcDevOrg7Hn2KmQ4xW9sLtY), production (3HSzeProdOrg5Jd8VpN1cR6gTfB).

Route Behaviour
GET /api/organizations {data: [{id, identifier, name, type}]}: display name β†’ identifier.
GET /api/{org}/streams?type=logs|traces {list: [{name, stream_type, stats: {doc_num}}]}.
GET /api/{org}/streams/{stream}/schema?type=logs|traces {name, stream_type, schema: [{name, type}]}: every field ever ingested non-null. 404 before anything is ingested.
POST /api/{org}/_search?type=logs|traces {query: {sql, start_time, end_time (Β΅s), from, size}} β†’ {took, hits, total, from, size, scan_records, is_partial}.

Basic auth is required (401 Unauthorized Access, text/plain). {org} must be an org identifier: a display name (/api/production/_search, as release-conductor sends) is the same bare 401 real O2 answers, so that bug stays visible.

The SQL subset: SELECT * | expr [AS alias], … FROM "<stream>" [WHERE …] [GROUP BY …] [HAVING …] [ORDER BY expr [ASC|DESC], …] [LIMIT n [OFFSET m]] with AND/OR/NOT, =, !=/<>, <, <=, >, >=, IS [NOT] NULL, [NOT] IN, [NOT] LIKE/ILIKE, [NOT] BETWEEN (ISO strings compare against _timestamp), and str_match, str_match_ignore_case, match_all, re_match, lower, upper, tostring, length, coalesce, count(*), count([DISTINCT] x), min, max, sum, avg. Rows outside start_time–end_time are excluded, the default order is _timestamp DESC, LIMIT applies before from/size paging. Column names are case-sensitive and must exist in the stream's schema: clientUserId or a never-ingested column is 400 … No field named …, as our clients expect. A query on a stream with no data answers empty hits.

Admin (beyond the standard contract)

Route Effect
GET /__admin/logs?service=&event=&severity=&trace_id=&org= Stored log rows (with _org, _stream).
GET /__admin/spans?service=&name=&trace_id=&org= Stored span rows.
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.
GET /__admin/otlp-metrics {requests, bytes, metrics} for /v1/metrics.
PUT /__admin/settings {ingestTokens?, searchUsers?: [{username, password}], organizations?: [{identifier, name}], routing?: {byEnvironment, default}, keepBodies?} for the calling namespace.

Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}): rate_limited (429 + retry-after: 1), bad_gateway (502), unavailable (503), gateway_timeout (504) β€” all retried by the SDK β€” server_error (500) and unauthorized (401), which the SDK drops, partial_success (200 rejecting every item, JSON only) and search_unavailable (O2 search 503).

Namespaces

x-mockingbird-namespace, a /ns/<name> prefix on the endpoint and base URL, or by credential: PUT /__admin/credentials {"credentials": {"<OTEL_AUTH_TOKEN>": "w1", "<O2 username>": "w1"}} (map both so a worker's exports and searches meet).

Deliberately not modelled

  • gRPC OTLP (port 4317); only OTLP/HTTP.
  • Metrics storage and the O2 metrics/PromQL APIs: metric exports are counted only.
  • O2's full SQL dialect (DataFusion): joins, subqueries, window functions, arithmetic, histogram(), approx_*; only the subset above.
  • O2 ingestion via /api/{org}/{stream}/_json or /api/{org}/v1/logs, dashboards, alerts and the UI; the collector's own batching, tail sampling and transform processors.
  • partialSuccess for protobuf requests (an empty success response is sent).
  • Log bodies by default (see --keep-bodies).

API

Export Kind Description
OtelAPI class The in-process mock: fetch(request), reset(), logs(), spans(), state. Options: sqlite, now, namespace, settings.
createRuntime function The mock with the full service contract (health, admin, wait, namespaces, credentials, presets). Options: settings, clock, seed, adminKey, onLog, sqlite.
OTEL_PRESETS object Every named fault preset.
OTEL_NAMESPACE string The service name, "otel".
otelCredential function The OTLP bearer token or the O2 Basic username (how credentials map to namespaces).
adminRow function A stored row as the admin routes show it (_org, _stream plus its columns).
DEFAULT_ORGANIZATIONS, DEFAULT_SETTINGS values The seeded orgs and settings.
logRows, spanRows, formatKey functions OTLP/JSON export β†’ O2 rows, and O2's field-name normalisation.
decodeLogsRequest, decodeTraceRequest, ProtobufError functions, class The protobuf decoder: OTLP protobuf β†’ OTLP/JSON shape.
parseSql, execute, referencedColumns, SqlError functions, class The SQL subset: parse, run over rows, list the columns a query reads.
document, operationIds, supportedOperationIds values The OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server) Node Serve over node:http; the serve CLI target; port 8809.

Part of mockingbird.

↑↓ to navigate↡ to open