Integration guide

Two ways in. Neither of them rewrites your service.

OpticTrace either sits in front of your service as a sidecar, or inside it as middleware. Both routes run the same rule engine against the same optic.yaml, so you can start with the sidecar, move to in-process later, and keep the policy file you already wrote.

Step zero

One question decides it

Can you change the application’s code? If not — a vendor binary, a service nobody owns any more, a language with no SDK here — the sidecar is the whole answer and needs no cooperation from the process it fronts. If you can, in-process middleware buys you one thing the sidecar cannot: sensitive values are masked inside the process that saw them and never cross a boundary in the clear.

Sidecar / gateway

optictrace run

A reverse proxy in front of your service. Zero code change, any language, any framework — including ones that do not exist yet.

  • Nothing to import, nothing to redeploy in the app
  • One network hop added
  • Raw payloads reach the agent’s memory before being governed
  • Best first move for an existing system

In-process middleware

Express · FastAPI · Java · Go · Gin

The same engine inside your app. Governance runs before any byte leaves the process, and the agent stores what it is sent.

  • A card number is masked in the JVM, interpreter or goroutine that saw it
  • No extra hop, no extra container
  • Agent downtime never touches your request path — shipping is fire-and-forget
  • Needs one dependency and roughly five lines

They compose. A sidecar in front of the services you cannot change, and the middleware inside the ones you can, all reporting into one agent in collector mode. Records carry the emitting service name, so the fleet stays separable on every chart and in every Prometheus series.

Before either route

Write one optic.yaml, or generate it

The same file is read by the sidecar and by every SDK. Parsing is strict — unknown keys are rejected rather than ignored, so a typo like restirct: fails at load instead of quietly disabling your governance.

Generate a first draft from a spec you already have

If there is an OpenAPI 3.x or Swagger 2.0 document, most of a first draft can be derived from it: credential headers from declared securitySchemes, metadata-only capture on login-shaped routes, and redaction for payload fields whose names are unambiguous — each annotated with its confidence and reason.

# the config goes to stdout; the caveats go to stderr, so a redirect
# still leaves you a clean file
optictrace init -spec openapi.yaml > optic.yaml

# or name the file, in which case it refuses to overwrite a reviewed one
optictrace init -spec openapi.yaml -out optic.yaml

A spec describes what an API claims. A field the document does not model cannot be masked by a rule derived from it, which is why the generated file leads with its own caveats and why scan below exists.

Or start from the minimum and grow it

Three keys are enough to run. Everything else has a default.

version: 1

service:
  name: payments-api
  listen: ":8080"              # sidecar only — omit for SDK / collector mode
  upstream: "http://127.0.0.1:9000"

telemetry:
  admin_listen: "127.0.0.1:9095"   # dashboard · /metrics · query APIs
  store:
    driver: sqlite
    dsn: optic.db

rules:
  - name: redact-payment-secrets
    match:
      path: "/api/v1/payments/**"
    redact:
      headers: [Authorization, Cookie]
      json_fields:
        - "$.**.card.number"
        - "$.**.card.cvv"
    labels:
      tenant: "header:X-Tenant-ID"

Validate it before anything depends on it

validate is a lint pass with no traffic involved. test asserts what a rule does to a payload, so a redaction you rely on cannot silently stop matching after a refactor.

optictrace validate -config optic.yaml
optictrace test     -config optic.yaml   # reads optic.test.yaml beside it
Route one

Sidecar, four ways

The pattern is identical everywhere: OpticTrace takes the port clients already call, your service moves to a private one, and upstream points at it. The response comes back byte-for-byte — same status, same headers, same body — so nothing downstream of the client can tell the difference.

A. On a host, from the binary

no container

Two moves: your service stops listening publicly, OpticTrace takes over that port.

# install
brew install dwarka-prasad/tap/optictrace
# or
go install github.com/dwarka-prasad/optictrace/cmd/optictrace@latest

# your app moves off 8080 (say to 9000), then:
optictrace run -config optic.yaml

telemetry.admin_listen defaults to loopback. The admin port serves the dashboard and every captured payload, so exposing it means turning on telemetry.auth in the same change, not later.

B. Docker, alongside an existing container

ghcr.io

The published image already has the dashboard compiled in and runs as a non-root user. Its default command reads /etc/optictrace/optic.yaml, so the only thing to mount is the config.

docker run --rm \
  -p 8080:8080 -p 9095:9095 \
  -v "$PWD/optic.yaml:/etc/optictrace/optic.yaml:ro" \
  -v optictrace-data:/data \
  ghcr.io/dwarka-prasad/optictrace:latest

upstream must be reachable from the agent’s network namespace. Pointing it at 127.0.0.1 from inside a container is the most common first mistake — use the service name on a shared Docker network, or host.docker.internal on Docker Desktop.

C. Docker Compose, in front of a service you already have

Give the agent the published port and take it away from the app. The app keeps its own port on the internal network, so nothing about it changes.

services:
  api:
    build: .
    expose: ["9000"]          # internal only — no ports: any more

  optictrace:
    image: ghcr.io/dwarka-prasad/optictrace:latest
    ports: ["8080:8080", "9095:9095"]
    volumes:
      - ./optic.yaml:/etc/optictrace/optic.yaml:ro
      - optictrace-data:/data
    depends_on: [api]

volumes:
  optictrace-data:
# optic.yaml — upstream is the compose service name
service:
  name: api
  listen: ":8080"
  upstream: "http://api:9000"

The repository’s own docker-compose.yml is a working version of this with Prometheus and Grafana attached and a seeder that drives multi-tenant traffic on startup — an empty dashboard tells you nothing about whether the rules work. docker compose up --build.

D. Kubernetes

two shapes

As a true sidecar, sharing a Pod with the app — the agent talks to it over localhost, and the Service is repointed at the agent’s port:

spec:
  template:
    spec:
      containers:
        - name: api
          # unchanged — still listening on 9000

        - name: optictrace
          image: ghcr.io/dwarka-prasad/optictrace:latest
          ports:
            - { name: proxy, containerPort: 8080 }
            - { name: admin, containerPort: 9095 }
          env:
            - name: OPTICTRACE_ADMIN_TOKEN
              valueFrom:
                secretKeyRef: { name: optictrace-admin, key: token }
          volumeMounts:
            - { name: optic-config, mountPath: /etc/optictrace, readOnly: true }
          readinessProbe:
            httpGet: { path: /healthz, port: admin }
      volumes:
        - name: optic-config
          configMap: { name: optic-config }     # holds optic.yaml
# in optic.yaml — same Pod, so localhost is correct here
service:
  listen: ":8080"
  upstream: "http://127.0.0.1:9000"

telemetry:
  admin_listen: "0.0.0.0:9095"    # the probe is another container
  auth:
    token_env: OPTICTRACE_ADMIN_TOKEN

Or as a shared gateway in front of several backends, which is what the bundled Helm chart deploys — ConfigMap-managed config, health probes, an optional PVC for the SQLite store, and a ServiceMonitor for Prometheus Operator:

helm install optictrace ./deploy/helm/optictrace \
  --set config.service.upstream="http://my-api:80" \
  --set persistence.enabled=true \
  --set serviceMonitor.enabled=true

The chart turns admin auth on by default and generates a token on first install, preserved across upgrades. Inside a cluster the admin port is reachable by anything that can resolve the Service, and it serves captured payloads — turning that off should be a decision, not an accident.

# read the generated token
kubectl get secret optictrace-admin-token -o jsonpath='{.data.token}' | base64 -d
Route two

In-process middleware

Every SDK does the same four things: evaluate your optic.yaml, tee the request and response without buffering-and-replaying them, apply the policy, and ship the governed record to the agent. Shipping is fire-and-forget on a background worker — agent downtime never affects your request path.

With an SDK the agent is not proxying anything, so it runs in collector mode: leave service.listen and service.upstream out of the config entirely and it starts admin-only.

None of the SDKs are on a public registry yet — not npm, not PyPI, not Maven Central. Each install step below is the real one: from the repository. The agent itself is published, on Homebrew, go install and ghcr.io.

# the agent for an SDK fleet — no listen, no upstream
optictrace run -config optic.yaml
optictrace collector mode — no proxy listener  service=shop-api rules=4

Node.js — Express

@optictrace/express
# not on npm yet — install from the repository
git clone https://github.com/dwarka-prasad/optictrace
npm install ./optictrace/sdks/express
const express = require('express');
const optictrace = require('@optictrace/express');

const app = express();
app.use(optictrace({
  configPath: 'optic.yaml',
  agentUrl: 'http://localhost:9095',   // omit to log JSON to stdout
}));
app.use(express.json());                // AFTER optictrace

Mount it before your body parser. The middleware tees the raw request stream; a parser that has already consumed it leaves nothing to capture.

Log lines, filed under the request that wrote them:

const { LogShipper } = require('@optictrace/express');
const log = new LogShipper('http://localhost:9095', 'checkout');

log.info('charge captured', { amount: 129.0 });
// delivery is counted, not assumed: log.sent / log.failed / log.dropped

And a call this service makes downstream:

const headers = { ...optictrace.outboundHeaders() };
await fetch(url, { headers });   // carries THIS hop's span, so the next nests under it

Rules hot-reload on kill -HUP <pid>, mirroring the agent.

Python — FastAPI, Starlette, any ASGI app

optictrace-fastapi
# not on PyPI yet — pip installs it straight from the subdirectory
pip install "git+https://github.com/dwarka-prasad/optictrace#subdirectory=sdks/fastapi"
from fastapi import FastAPI
from optictrace_fastapi import OpticTraceMiddleware

app = FastAPI()
app.add_middleware(
    OpticTraceMiddleware,
    config_path="optic.yaml",
    agent_url="http://localhost:9095",   # omit to log JSON to stdout
)

Logging and downstream propagation:

import logging
from optictrace_fastapi import OpticTraceLogHandler, outbound_headers

logging.getLogger().addHandler(
    OpticTraceLogHandler("http://localhost:9095", "checkout"))

# a downstream call carries this hop's span
httpx.post(url, headers=outbound_headers(), json=payload)

It is pure ASGI — no FastAPI import inside the middleware — so Starlette, Litestar or a hand-rolled ASGI app all work the same way.

Java — Spring Boot, Quarkus, Jetty, Tomcat

optictrace-servlet

A plain jakarta.servlet.Filter, so anything on Servlet 5+ can use it. Not on Maven Central yet — build it into your local repository first:

git clone https://github.com/dwarka-prasad/optictrace
(cd optictrace/sdks/java && mvn install -DskipTests)
<dependency>
  <groupId>io.github.dwarka-prasad</groupId>
  <artifactId>optictrace-servlet</artifactId>
  <version>0.10.1</version>
</dependency>
@Bean
FilterRegistrationBean<OpticTraceFilter> optictrace() throws IOException {
    var f = new OpticTraceFilter("optic.yaml", "http://localhost:9095", "checkout");
    var reg = new FilterRegistrationBean<>(f);
    reg.setOrder(Ordered.HIGHEST_PRECEDENCE);   // see the bytes the client sent
    reg.addUrlPatterns("/*");
    return reg;
}

Logging and downstream propagation:

Logger parent = Logger.getLogger("com.example.shop");
parent.setLevel(Level.FINE);        // see the note below
parent.addHandler(new OpticTraceLogHandler("http://localhost:9095", "checkout"));

// on an outbound call
HttpHeaders h = new HttpHeaders();
TraceContext.outboundHeaders().forEach(h::set);

java.util.logging filters on the logger level before a handler is consulted, and its default is INFO. Without raising it, debug lines are never shipped — and the setup looks like it is working, because everything at info and above arrives normally.

Spring Boot 2 / Tomcat 9 need the javax.servlet variant. It is generated rather than kept as a second copy — two copies of one engine drift, and the copy nobody runs is the one that drifts:

(cd sdks/java && ./scripts/gen-javax.sh)   # -> target/javax-src

Go — net/http and Gin

same engine as the agent

The Go path is not a reimplementation: it is the agent’s own interceptor exposed as middleware, so it inherits every feature the proxy has by construction.

go get github.com/dwarka-prasad/optictrace
import (
    "github.com/dwarka-prasad/optictrace"
    optictracegin "github.com/dwarka-prasad/optictrace/sdks/gin"   // Gin only
)
agent, err := optictrace.New("optic.yaml")
if err != nil { log.Fatal(err) }
defer agent.Close()
agent.ServeAdmin("ui/out")   // dashboard + /metrics on the admin port

// net/http
http.ListenAndServe(":8080", agent.Middleware(mux))

// Gin
r.Use(optictracegin.Middleware(agent))

Logging and downstream propagation:

logger := slog.New(optictrace.NewLogHandler("http://localhost:9095", "checkout", nil))
logger.InfoContext(ctx, "charge captured", "amount", 129.0)

for k, v := range optictrace.OutboundHeaders(ctx) { req.Header.Set(k, v) }

Use slog’s ...Context variants. A plain logger.Info() passes context.Background(), which carries no span, so those lines arrive as orphans and the agent drops them by default.

Optional: name the operations inside a request

all five

Everything above records the HTTP exchange. Inner spans break one hop down into the work inside it, so a request that took 300ms can say that 280 of them were one query. It is opt-in twice over: the policy has to be on, and each operation appears because someone named it — nothing hooks your database driver.

telemetry:
  spans:
    enabled: true
    min_duration: 1ms        # a 20µs cache hit ×1000 is volume, not information
    max_per_request: 200   # the cap being HIT is itself the finding — usually an N+1
    max_attr_bytes: 4096
    retention_max_age: 72h
    redact:                  # attributes are free text, and a statement quotes its parameters
      patterns:
        - '\b\d{13,19}\b'
        - '[\w.+-]+@[\w-]+\.[\w.]+'
      fields: [cache.key]
// Go — the context carries the nesting
ctx, sp := spans.Start(ctx, "db.query", "db")
sp.Set("db.statement", "SELECT * FROM orders WHERE id = $1")
defer sp.End()

// and every outbound call, timed AND propagated, with no call site changes
client := &http.Client{Transport: spans.Transport(nil)}
// Java — try-with-resources closes the span
try (InnerSpan sp = spans.start("db.query", "db")) {
    sp.set("db.statement", SQL).setInt("db.rows", rows.size());
}
# Python — a context manager; an exception is recorded and re-raised
with spans.start("db.query", "db") as sp:
    sp.set("db.statement", SQL).set_int("db.rows", len(rows))
// Node — observe() establishes the scope that makes operations nest
await spans.observe('db.query', 'db', (sp) => {
  sp.set('db.statement', SQL);
  return pool.query(SQL, [id]);
});

Pass the statement TEMPLATE, not the interpolated one. The agent redacts what arrives — a driver that interpolates its parameters puts the customer's email in the one attribute a breakdown most wants to show, and it is stored [REDACTED] — but it cannot un-send it. The safest secret is the one that was never transmitted.

Node needs observe() for nesting, or an explicit { parent }. There is no way to leave an AsyncLocalStorage scope imperatively, so start() alone cannot establish one for whatever runs next. The other four nest from start() because their languages let a scope be popped.

A failed operation is kept however fast it was — "it returned in 200µs" and "it returned in 200µs with an error" are not the same event. Work outside a request is dropped by default and counted, and optictrace purge deletes a tenant's spans with their records in one transaction.

What each route gives you
CapabilitySidecarGo / GinExpressFastAPIJava
Restriction & redaction
Labels, meters, sampling
Tail sampling (keep_errors, keep_slower_than)
W3C trace context
trace.response_header
Inner spans (db · cache · outbound)names the exchange only✓ + auto outbound
Application log shippingvia -exec / file✓ slog✓ JUL
Masks data inside your process
Needs no code change
Serves the dashboard itselfvia agentvia agentvia agent
Adopting it mid-flight

Adding this to something already in production

The temptation is to write the rules first and turn everything on at once. That gets it backwards: you do not yet know what your traffic actually contains, and the first thing a governance tool should do is tell you rather than assume. The order below never has a step where the store holds data you did not intend it to.

Start with capture off entirely

Deploy with metadata only — status, latency, byte counts, labels. You get golden signals and per-tenant attribution on day one, and no payload is stored anywhere while you are still deciding what the rules should be.

defaults:
  capture:
    request_body: false
    response_body: false
    headers: false

Find out what the traffic contains, from the traffic

suggest reads field and header names and proposes rules. scan inspects values against detectors — card numbers, tokens, emails — and finds what naming conventions miss. They answer different halves of the same question, so run both.

optictrace suggest -config optic.yaml -window 24h -apply proposed.yaml
optictrace scan    -config optic.yaml -window 24h
⚠ [high] credit-card in POST /api/** → request_body.$.ref
    a Luhn-valid card number — PCI-DSS scope · sample 41••••••••••••11
    fix: redact:
           json_fields: ["$.ref"]

Names alone are not enough. That finding is a field called ref holding a card number: invisible to suggest, obvious to scan, and reported with the rule fragment that would have masked it. This is also why the step above turns capture off — you want these two to be the first things that read a payload.

Turn capture on route by route, redaction first

Merge the proposed rules, then enable bodies only where a rule already masks what matters. A route with no rule is a route with no protection, and scan will keep saying so.

optictrace validate -config optic.yaml
optictrace test     -config optic.yaml   # pin what each rule does

Sample the hot paths, but never lose the interesting requests

A high-volume read path does not need every body stored. Sampling gates the body only — the record is always written, so no count, rate or percentile changes — and the tail-based rules rescue the ones worth having after the outcome is known.

  - name: sample-catalog-reads
    match: { path: "/api/v1/catalog/**", methods: [GET] }
    sample: 0.4
    keep_errors: true          # 5xx are always kept
    keep_slower_than: 200ms   # and so is anything slow

Check the result on the Capture & sampling panel. It is the only honest read on this: a rule sampling at 0.05 that matches nothing looks identical to one at 1.0 in every other number on the page.

Keep the policy honest as the API changes

Governance rots the moment someone adds a field. Wire the review into CI, where it diffs the config against the base branch and re-runs the scan over recent traffic — a rule that used to cover a route and now does not becomes a failing check rather than a discovery six months later.

optictrace review -config optic.yaml \
  -base-config base/optic.yaml \
  -from http://optictrace.internal:9095

Nothing here is one-way. The sidecar and the SDKs read the same file, so a service that starts behind a proxy and later imports the middleware keeps its policy verbatim — and the config it was validated against is the config it still runs.

Before you call it done

Prove it is actually working

Every one of these checks exists because the corresponding failure has happened here, and each looked healthy from the outside while it was happening.

Records are arriving
curl -s localhost:9095/api/stats | jq .total

A Python SDK once passed every offline test it had while a live agent rejected 100% of its records — the timestamps were not strict RFC3339 and the failure was swallowed. Nothing offline can catch that. If you write SDK tests, run them with OPTIC_AGENT_URL set so a real agent has to accept the output.

The secret is not in the store
curl -s 'localhost:9095/api/logs?limit=50' \
  | grep -c '4111111111111111'   # want: 0

Read it out of the store rather than trusting the rule. A redaction path that matches nothing and a redaction path that works both produce a dashboard with no card numbers on the screen you are looking at.

The trace joins up
curl -s 'localhost:9095/api/traces?window=1h' \
  | jq '.traces[0] | {spans, services}'

More than one hop means your outbound propagation is working. One hop on a request you know fans out means the traceparent is not being copied onto the downstream call.

Log lines are landing on their request
curl -s localhost:9095/api/applogs/stats

Watch spans_with_logs, not just total. Lines with no request behind them are dropped by default and counted in optictrace_app_logs_dropped_total — a high drop count usually means logging outside a request, or a span that never reached the logger.

Nothing is ungoverned
optictrace scan -config optic.yaml -window 1h -fail-on high

Exits non-zero on a finding, so it belongs in CI. A route nobody wrote a rule for is the normal way sensitive data ends up stored.

Traffic is untouched
diff <(curl -s localhost:9000/api/v1/orders) \
     <(curl -s localhost:8080/api/v1/orders)

Compare your service directly against the proxied path. They must be identical: live traffic is never modified, and the one deliberate exception is the traceparent on the forwarded request — never the response, never what the client sent.