Tracing & Performance
GoodLogs collects distributed traces using OpenTelemetry conventions. Every span flows through the unified /v1/envelope endpoint and lands in a hypertable, where it powers the Performance and Trace dashboard pages.
What you get
| Dashboard | What it shows |
|---|---|
/dashboard/performance | Endpoint table sorted by p95 (count, p50, p95, p99, errors), Core Web Vitals tiles, slowest pages by LCP, recent slow traces |
/dashboard/traces/<trace_id> | Full waterfall with op-coloured bars, status dots, sticky attribute + event panel |
/dashboard/web-vitals | Rating distributions (good / needs-improvement / poor), per-route p75 tables |
/dashboard/explorer with from:spans … | Ad-hoc filtering and aggregations |
Span model
Each span follows the OTel data model:
{
"type": "span",
"span_id": "0123456789abcdef", // 16-hex W3C
"trace_id": "0123456789abcdef0123456789abcdef", // 32-hex W3C
"parent_span_id": "fedcba9876543210", // null for roots
"name": "GET /checkout",
"op": "http.server", // grouping key
"status": "ok", // ok | error | unset
"kind": "server", // server | client | internal | producer | consumer
"duration_ms": 123,
"service": "api",
"environment": "production",
"release": "v1.4.2",
"attributes": { "http.method": "GET", "http.route": "/checkout", "http.status_code": 200 },
"events": [{ "ts": "...", "name": "db.query.start" }]
}
These columns are promoted out of attributes on ingest so they're cheap to index and query:
http_method, http_route, http_status_code, db_system.
Manual instrumentation
JavaScript / TypeScript
const tx = gl.startTransaction({ name: "/checkout", op: "http.server" })
tx.setAttribute("user.id", userId)
const dbSpan = tx.startChild({ op: "db.query", name: "SELECT users" })
dbSpan.addEvent("query.start")
// ...
dbSpan.setStatus("ok")
dbSpan.finish()
tx.setStatus("ok")
tx.finish()
Python
with gl.start_transaction(name="/checkout", op="http.server") as tx:
tx.set_attribute("user.id", user_id)
with tx.start_child(op="db.query", name="SELECT users") as db_span:
db_span.add_event("query.start")
run_query()
The Python Span is a context manager — on __exit__ it auto-finishes and flips status to error if the block raised.
Auto-instrumentation
Browser
captureFetch is on by default when useEnvelope: true. Every fetch() call becomes an op:http.client span with http.method, http.url, http.status_code, and a W3C traceparent header injected into the outgoing request. Calls to the SDK's own ingest endpoint are skipped to avoid feedback loops.
Node servers
Pick the adapter for your framework:
import express from "express"
import { GoodLogs, goodlogsExpress, instrumentNodeHttp } from "@aj-2000-test/goodlogs-sdk"
const gl = new GoodLogs({ apiKey: "gl_sk_...", useEnvelope: true })
instrumentNodeHttp(gl) // outbound http / https → http.client spans
const app = express()
app.use(goodlogsExpress(gl)) // inbound → http.server spans
// Fastify
import { goodlogsFastify } from "@aj-2000-test/goodlogs-sdk"
app.register(goodlogsFastify(gl))
// NestJS
import { goodlogsNestInterceptor } from "@aj-2000-test/goodlogs-sdk"
{ provide: APP_INTERCEPTOR, useValue: goodlogsNestInterceptor(gl) }
All three adapters honour incoming traceparent so multi-service traces stitch together.
Python servers
from flask import Flask
from goodlogs import GoodLogs, goodlogs_flask, instrument_requests
gl = GoodLogs("gl_sk_...", use_envelope=True)
instrument_requests(gl) # outbound requests.get/post → http.client spans
app = Flask(__name__)
goodlogs_flask(gl, app) # inbound → http.server spans
# FastAPI
from goodlogs import goodlogs_fastapi
goodlogs_fastapi(gl, app)
# Django settings.py
from goodlogs import GoodLogs, goodlogs_django_middleware
GOODLOGS = GoodLogs("gl_sk_...", use_envelope=True)
# myapp/tracing.py
from django.conf import settings
from goodlogs import goodlogs_django_middleware
goodlogs_middleware = goodlogs_django_middleware(settings.GOODLOGS)
# settings.py
MIDDLEWARE = ["myapp.tracing.goodlogs_middleware", ...]
Cross-service propagation
Every server-side adapter reads the traceparent header from the incoming request and uses it as the new span's trace_id. Every outbound HTTP client (fetch, instrument_requests, instrumentNodeHttp) injects traceparent on outgoing requests in the standard W3C shape:
traceparent: 00-<32-hex trace>-<16-hex parent_span>-01
So a browser request → Express → Flask service → another Node service all share one trace.
Trace-from-request-id (zero SDK)
If you can't install the SDK yet but already log structured data with a request_id, correlation_id, or trace_id field, a background worker still synthesises a span per request:
- runs every 60 seconds,
- only kicks in for projects with no native spans in the last hour,
- groups logs by their correlation key into one synthetic span per request,
- marks the span as
errorif any log in the bucket was atseverity:error|fatal, - writes them with
op:synthetic, kind:internalandattributes.synthetic = "logs_to_traces"so you can identify them in GQL.
from:spans op:synthetic | count by service | last:24h
Once your project emits native spans the scanner backs off automatically.
Querying
# Endpoint latencies last hour, slowest first
from:spans op:http.server | p50(duration_ms), p95(duration_ms), p99(duration_ms) by http_route | last:1h
# All spans in a given trace, time-ordered (what the waterfall page uses)
from:spans trace_id:0123... | last:30d limit:500
# Filter via the OTel attributes bag
from:spans attributes.db.system:postgres | count by op | last:24h
# Failing checkout requests
from:spans op:http.server http_route:/checkout status:error | last:7d