Skip to content

Telemetry: Nwire → OpenTelemetry → GreptimeDB

Stream every action, event, transition, and query out of your Nwire app into GreptimeDB — a time-series + observability database that speaks OTLP natively, exposes a PostgreSQL wire protocol for SQL, and is fast enough to render Studio Live history from cold storage.

Two routing paths to pick from:

PathHopsWhen to use
A1Single service, no sampling needed. Direct OTLP/HTTP — simplest.
B2Sampling, multi-sink fan-out, or buffering. Vector in the middle.

0. Boot the stack (local dev)

bash
docker compose -f docker-compose.yml -f docker-compose.telemetry.yml up -d

This adds two services to your local stack:

ServicePortsPurpose
greptimedb4000 HTTP, 4001 gRPC, 4003 PGStorage + SQL
vector4317 gRPC, 4318 HTTP, 8686 APIRouting layer (Path B only)

Health-check:

bash
curl http://localhost:4000/health     # greptime
curl http://localhost:8686/health     # vector

Path A — direct OTLP/HTTP → Greptime

The minimum-moving-parts shape. Greptime accepts OTLP natively at POST /v1/otlp/v1/traces — no Vector required.

App wiring

bash
pnpm add @nwire/telemetry-otel \
         @opentelemetry/api \
         @opentelemetry/sdk-trace-node \
         @opentelemetry/exporter-trace-otlp-proto \
         @opentelemetry/resources \
         @opentelemetry/semantic-conventions

Use exporter-trace-otlp-proto, not -http. Greptime's OTLP endpoint only accepts application/x-protobuf; the -http exporter sends JSON and will be rejected with a clear 400 error.

ts
// apps/main/wires/telemetry.ts
import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";

export function bootTracing(serviceName: string): void {
  const provider = new NodeTracerProvider({
    resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: serviceName }),
    spanProcessors: [
      new BatchSpanProcessor(
        new OTLPTraceExporter({
          url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
            ?? "http://localhost:4000/v1/otlp/v1/traces",
          // REQUIRED — Greptime routes OTLP through its `greptime_trace_v1`
          // pipeline; without this header the request 400s with
          // "Pipeline is required for this API.".
          headers: { "x-greptime-pipeline-name": "greptime_trace_v1" },
        }),
      ),
    ],
  });
  provider.register();
}
ts
// apps/main/main.ts
import { endpoint } from "@nwire/endpoint";
import { attachOtelExporter } from "@nwire/telemetry-otel";
import { trace } from "@opentelemetry/api";
import { bootTracing } from "./wires/telemetry";
import { app } from "./app";
import { api } from "./api";

bootTracing("station-management");
await app.start();
attachOtelExporter(app.runtime, { tracer: trace.getTracer("nwire") });
api.inspect(app);

await endpoint("api", { port: 3003 }).serve(api).run();

Query

bash
# Via psql (Greptime speaks the Postgres wire protocol):
psql "postgresql://greptime_user@localhost:4003/public"

> SELECT
    trace_id,
    span_name,
    duration_nano / 1e6 AS ms,
    span_attributes ->> 'persona' AS persona
  FROM traces
  WHERE service_name = 'station-management'
  ORDER BY timestamp DESC
  LIMIT 20;

Or via Greptime's HTTP API:

bash
curl -X POST http://localhost:4000/v1/sql \
  -H 'content-type: application/x-www-form-urlencoded' \
  --data 'sql=SELECT span_name, count(*) FROM traces GROUP BY span_name'

Path B — Vector in the middle (logs only)

Use when you need any of:

  • Log routing — newline-delimited JSON logs from @nwire/logger-pino
  • Fan-out — fork logs to multiple sinks (Greptime + S3 archive)
  • Sampling / enrichment — tag every record with deploy_env, region, tenant

Trace caveat: Vector's current http sink can't re-encode parsed OTLP events back into protobuf, and Greptime's OTLP endpoint only accepts application/x-protobuf (JSON returns a clear error). For traces, use Path A direct, or put an OTel Collector (otel/opentelemetry-collector:latest) in front of Greptime instead of Vector.

Log shipping

ts
// app side: pino-pretty or just newline-JSON to stdout, then any log
// shipper (filebeat / vector tail / k8s pod log) → Vector OTLP source.

The Vector → Greptime log hop is defined in .docker/vector.toml:

toml
[sinks.greptime_logs]
type = "http"
inputs = ["tag_env"]
uri = "http://greptimedb:4000/v1/events/logs?db=public&table=nwire_logs"
encoding.codec = "json"
framing.method = "newline_delimited"

Add a Honeycomb sink alongside Greptime by editing .docker/vector.toml:

toml
[sinks.honeycomb_traces]
type = "honeycomb"
inputs = ["tag_env"]
api_key = "${HONEYCOMB_API_KEY}"
dataset = "nwire-prod"

Reload Vector to pick it up:

bash
docker compose restart vector

Production checklist

DecisionRecommendation
Where does Greptime run?Cloud-managed (greptime.cloud) or your K8s cluster — never on the app box.
Sampling rate for query.executed?Default query.executed to ~10% in prod; keep 100% in staging.
What carries tenant?MessageEnvelope.tenant flows through every record. Index on it in Greptime.
How long to retain?Greptime has TTL policies per table — typically 7 days hot, 90 days cold.
Failure mode if Greptime is down?With Vector: app keeps running, Vector buffers to disk. Without: spans drop silently.

What you get free

Because @nwire/telemetry-otel walks the canonical Telemetry stream (13 kinds) and Greptime renders the standard OTLP schema:

  • Causation-id-keyed span parents — every reaction shows its triggering event
  • persona + journeyStep + slo from defineAction({...}) land as span attributes
  • Tenant + correlation IDs carried automatically through the envelope chain
  • actor.transitioned records become span events on the actor's span — you see state-machine transitions inside the same trace as the action that triggered them

Studio's Trace page reads the same /_nwire/telemetry/* SSE stream in-process; Greptime stores the same records for cross-deploy history. One stream, two consumers.

MIT licensed.