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

DashboardWhat it shows
/dashboard/performanceEndpoint 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-vitalsRating 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:

jsonc
{
  "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

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

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:

typescript
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
typescript
// Fastify
import { goodlogsFastify } from "@aj-2000-test/goodlogs-sdk"
app.register(goodlogsFastify(gl))
typescript
// 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

python
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
python
# FastAPI
from goodlogs import goodlogs_fastapi
goodlogs_fastapi(gl, app)
python
# 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:

perl
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 error if any log in the bucket was at severity:error|fatal,
  • writes them with op:synthetic, kind:internal and attributes.synthetic = "logs_to_traces" so you can identify them in GQL.
gql
from:spans op:synthetic | count by service | last:24h

Once your project emits native spans the scanner backs off automatically.

Querying

gql
# 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