@crvouga/mockingbird-service-otel

中文文档
3.11.9发布于 2026/11/03MITTypeScriptES Module构建来源已验证近 30 天下载 1.6万

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

原始使用文档
文档目录8 节
  1. Install
  2. Usage
  3. Receiver
  4. O2 search
  5. Admin (beyond the standard contract)
  6. Namespaces
  7. Deliberately not modelled
  8. API

@crvouga/mockingbird-service-otel

Local emulators. Real API contracts. Part of Mockingbird.

Stateful emulator 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

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 emulator and set OTEL_TRACES_SAMPLER_ARG=1 (our SDK samples 10% of root spans otherwise). Point O2_BASE_URL at the same emulator; O2_BASIC_AUTH is any base64 user:password unless --search-auth is set.

npx mockingbird-otel serve --port 8809 --ingest-token "$OTEL_AUTH_TOKEN"
# a local collector that takes no credentials, with a browser exporter allowed in
npx mockingbird-otel serve --ingest-auth none --cors-origins http://localhost:5173
# a hosted OTLP gateway: Authorization: Basic base64(instanceId:token)
npx mockingbird-otel serve --ingest-basic-auth "$OTLP_INSTANCE_ID:$OTLP_TOKEN"
import { SeverityNumber } from "@opentelemetry/api-logs"
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"
import { resourceFromAttributes } from "@opentelemetry/resources"
import { LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"
import { createServer } from "@crvouga/mockingbird-service-otel/server"

const server = await createServer()

// The OpenTelemetry SDK your application already uses. Only the exporter's URL changes.
const exporter = new OTLPLogExporter({
  url: `${server.url}/v1/logs`,
  headers: { authorization: "Bearer ingest-token" },
})
const loggerProvider = new LoggerProvider({
  resource: resourceFromAttributes({ "service.name": "backend" }),
  processors: [new SimpleLogRecordProcessor(exporter)],
})
loggerProvider.getLogger("backend").emit({
  severityNumber: SeverityNumber.INFO,
  severityText: "info",
  body: "reconcile done", // dropped: bodies are never stored
  attributes: { event: "initial_credit_reconcile_completed", granted: 1 },
})
await loggerProvider.forceFlush()

// Test control: long-poll until the event lands.
await fetch(`${server.url}/__admin/wait`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ kind: "log", where: { event: "initial_credit_reconcile_completed" } }),
})

// Then query it the way our O2 clients do (OpenObserve's search API is plain HTTP).
const search = await fetch(`${server.url}/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'` },
  }),
})
console.log(await search.json())
// { hits: [{ service_name: "backend", event: "…", granted: 1, severity_text: "info", _timestamp }], … }

await loggerProvider.shutdown()
await server.close()

Receiver

RouteBehaviour
POST /v1/tracesExportTraceServiceRequest, application/json or application/x-protobuf (optionally content-encoding: gzip) → 200 {partialSuccess: {}} (an empty protobuf response for protobuf requests).
POST /v1/logsExportLogsServiceRequest, same encodings and answers.
POST /v1/metricsAccepted and counted (GET /__admin/otlp-metrics), never stored.
OPTIONS /v1/traces, /v1/logs, /v1/metricsA browser exporter's CORS preflight, answered when cors.allowedOrigins is set (see Browser CORS); otherwise 405.

Undecodable payloads are 400, other content types 415. Every receiver error is a google.rpc.Status ({code, message}) in the request's encoding: binary protobuf for an application/x-protobuf request, JSON otherwise. The optional stream-name header picks the stream (default default).

Collector auth. The receiver authenticates exports the way the collector it stands in for does, so the exporter under test keeps its real headers. The mode is the ingestAuth setting (createRuntime({settings}), PUT /__admin/settings per namespace, or --ingest-auth):

ingestAuthAn export needsNarrowed by
bearer (default)Authorization: Bearer <token>: any token, 401 without one.ingestTokens / --ingest-token: only those tokens.
basicAuthorization: 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.
noneNothing: a local collector. An Authorization header an exporter still sends is ignored.—

A refused export is 401 {"code": 16, "message": "Unauthenticated"}. A mode takes only its own scheme (a bearer token is a 401 in basic mode and the reverse), credentials are checked before the payload is parsed, and in every mode an accepted payload is parsed and stored the same way. Only the receiver changes: O2 search always wants Basic credentials.

Browser CORS. Off by default: OPTIONS /v1/* is 405 and no response carries CORS headers. Set cors (same three places; --cors-origins, --cors-headers) to answer a browser exporter as the OpenTelemetry Collector's cors: block does:

import { createRuntime } from "@crvouga/mockingbird-service-otel"

createRuntime({
  settings: {
    ingestAuth: "none",
    cors: {
      allowedOrigins: ["https://app.example.test", "https://*.preview.example.test"], // or "*"
      allowedHeaders: ["authorization", "content-type", "traceparent", "tracestate"], // or "*"
      allowedMethods: ["POST"], // default GET, POST, HEAD
      exposedHeaders: [], // Access-Control-Expose-Headers on the export
      maxAge: 600, // Access-Control-Max-Age, seconds
    },
  },
})

A preflight (OPTIONS with Origin and Access-Control-Request-Method) is answered before authentication, always 204. When the origin, the requested method and every name in Access-Control-Request-Headers are allowed it carries Access-Control-Allow-Origin (the origin, or *), -Allow-Methods, -Allow-Headers (both echoing the request), -Allow-Credentials: true and -Max-Age; otherwise it carries none of them, which is what fails it in the browser. The export's own response, errors included, carries Access-Control-Allow-Origin, -Allow-Credentials and Vary: Origin for an allowed origin. With allowedHeaders unset only accept, content-type and x-requested-with pass; once it is set the list is exact, so name content-type (an OTLP content type is never CORS-safelisted) and the propagation headers your exporter adds. A preflight has no credential to pick a namespace by: it reads the default namespace's settings unless the endpoint uses the /__admin/ns/<name> prefix (settings passed to createRuntime apply to every namespace).

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: each event's name, time and attributes) and their attributes, with ids exactly as exported. 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).

RouteBehaviour
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)

RouteEffect
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.
GET /__admin/settingsThe calling namespace's settings.
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.

/__admin/spans, /__admin/logs and /__admin/wait are the export of what was ingested: one normalized row per span or log record, every attribute kept (see Storage above). The original OTLP request batches are not kept, and GET /__admin/requests records each export without its body.

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). They work in every auth mode and fault the export POST only, never a CORS preflight; their bodies are JSON whatever the request's encoding.

Namespaces

x-mockingbird-namespace, a /__admin/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). In basic mode the credential is the Basic username (the instance id); in none mode there is none, so use the header or the path prefix.

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).
  • The original OTLP batches, and the span fields no row holds: links, trace state, flags, the status message, the scope version and the dropped-item counts. start_time / end_time are JSON numbers, so nanoseconds past 2^53 round (_timestamp and duration, in µs, are exact).
  • A hosted gateway's own surface: its /otlp path prefix (point the exporter at the emulator's root, or set the per-signal endpoint), its token scopes and the wording of its 401. The 401 is the OTLP Status above.
  • The Collector's CORS library also refuses a preflight whose Access-Control-Request-Headers is not the lowercase, sorted list browsers send; here the names match in any order and case.
  • Log bodies by default (see --keep-bodies).

API

ExportKindDescription
OtelAPIclassThe in-process emulator: fetch(request), reset(), logs(), spans(), state. Options: sqlite, now, namespace, settings.
createRuntimefunctionThe 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.
OTEL_PRESETSobjectEvery named fault preset.
OTEL_NAMESPACEstringThe service name, "otel".
otelCredentialfunctionThe OTLP bearer token or the Basic username (how credentials map to namespaces).
adminRowfunctionA stored row as the admin routes show it (_org, _stream plus its columns).
DEFAULT_ORGANIZATIONS, DEFAULT_SETTINGS, INGEST_AUTH_MODESvaluesThe seeded orgs and settings (ingestAuth: "bearer", CORS off), and the auth modes.
logRows, spanRows, formatKeyfunctionsOTLP/JSON export → O2 rows, and O2's field-name normalisation.
decodeLogsRequest, decodeTraceRequest, ProtobufErrorfunctions, classThe protobuf decoder: OTLP protobuf → OTLP/JSON shape.
parseSql, execute, referencedColumns, SqlErrorfunctions, classThe SQL subset: parse, run over rows, list the columns a query reads.
document, operationIds, supportedOperationIdsvaluesThe OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server)NodeServe over node:http; the serve CLI target; port 8809.

Part of mockingbird.

版本与发布记录

@crvouga/mockingbird-service-otel 共发布 42 个版本,首次发布于 2026/09/23;过去 12 个月发布了 42 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.11.9latest2026/11/038 天前
3.11.82026/11/038 天前
3.11.72026/11/038 天前
3.11.62026/11/038 天前
3.11.52026/11/038 天前
3.11.42026/11/038 天前

关于 @crvouga/mockingbird-service-otel 的常见问题

@crvouga/mockingbird-service-otel 是什么?

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

如何安装 @crvouga/mockingbird-service-otel?

在项目目录运行 npm install @crvouga/mockingbird-service-otel,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 mockingbird-otel,可以用 npx mockingbird-otel 直接运行。

@crvouga/mockingbird-service-otel 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@crvouga/mockingbird-service-otel 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@crvouga/mockingbird-service-otel 可以商用吗?

@crvouga/mockingbird-service-otel 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@crvouga/mockingbird-service-otel 的下载量有多少?

根据 npm 官方下载统计,@crvouga/mockingbird-service-otel 在 2026/10/11 – 2026/11/09 这 30 天内被下载 16,397 次,日均约 547 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@crvouga/mockingbird-service-otel 还在维护吗?

最新版本 3.11.9 发布于 2026/11/03(8 天前);至今共发布 42 个版本,其中过去 12 个月发布了 42 个;首次发布于 2026/09/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。