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.
This recipe ships two routing paths. Pick one per app.
| Path | Hop count | When to use |
|---|---|---|
| A | 1 hop | Single Nwire service, no sampling needed. Simplest. Direct OTLP/HTTP. |
| B | 2 hops | Sampling, multi-sink fan-out, or buffering. Vector in the middle. |
0. Boot the stack (local dev)
docker compose -f docker-compose.yml -f docker-compose.telemetry.yml up -dThis adds two services to your local stack:
| Service | Ports | Purpose |
|---|---|---|
greptimedb | 4000 HTTP, 4001 gRPC, 4003 PG | Storage + SQL |
vector | 4317 gRPC, 4318 HTTP, 8686 API | Routing layer (Path B only) |
Health-check:
curl http://localhost:4000/health # greptime
curl http://localhost:8686/health # vectorPath 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
pnpm add @nwire/telemetry-otel \
@opentelemetry/api \
@opentelemetry/sdk-trace-node \
@opentelemetry/exporter-trace-otlp-proto \
@opentelemetry/resources \
@opentelemetry/semantic-conventionsUse
exporter-trace-otlp-proto, not-http. Greptime's OTLP endpoint only acceptsapplication/x-protobuf; the-httpexporter sends JSON and will be rejected with a clear 400 error.
// 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();
}// 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
# 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:
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
httpsink can't re-encode parsed OTLP events back into protobuf, and Greptime's OTLP endpoint only acceptsapplication/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
// 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:
[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:
[sinks.honeycomb_traces]
type = "honeycomb"
inputs = ["tag_env"]
api_key = "${HONEYCOMB_API_KEY}"
dataset = "nwire-prod"Reload Vector to pick it up:
docker compose restart vectorProduction checklist
| Decision | Recommendation |
|---|---|
| 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, Phase 34) and Greptime renders the standard OTLP schema:
- Causation-id-keyed span parents — every reaction shows its triggering event
persona+journeyStep+slofromdefineAction({...})land as span attributes- Tenant + correlation IDs carried automatically through the envelope chain
actor.transitionedrecords 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.