Operations › Observability
Introduction
Content
Using content
Plugins
Operations
Recipes
Observability
What you'll learn
Send traces and metrics from Easy CMS with OpenTelemetry: what it records, how to set up the SDK
in Next.js, Nuxt and easy-cms serve, and a local collector to try it.
Before this page: Migrations & deployment (logs and health checks).
JSON logs say what happened in each request. Traces say where its time went; metrics say how that changes over hours. Easy CMS sends both through OpenTelemetry, so they go to whatever your team uses: Jaeger, Grafana Tempo and Prometheus, Datadog, Honeycomb, New Relic, AWS X-Ray.
export default defineConfig({
telemetry: true,
// ...
})Easy CMS uses only @opentelemetry/api. Install it, and set up the OpenTelemetry SDK in your app
(below): the SDK decides where data goes. Without telemetry, Easy CMS doesn't load
@opentelemetry/api and does no extra work. telemetry: { traces: false } or
{ metrics: false } turns one of them off.
Traces
Each API request gets a span, with a span for each Local API call and each database call inside:
easy-cms GET /api/cms/:collection 42 ms (server)
└─ easy-cms find posts 40 ms
├─ db find posts 31 ms (client)
└─ db findByIds categories 6 ms (client)- Requests:
easy-cms <METHOD> <route>, with the route as a template (/api/cms/:collection/:id),http.request.method,http.routeandhttp.response.status_code. When a framework already traces the request (Next.js does), the span sits inside its span; witheasy-cms serveit is the server span itself. Health checks aren't traced. - Local API:
easy-cms find posts,easy-cms update posts,easy-cms findGlobal site… also when your own code calls the Local API inside a traced request. - Database:
db <call> <collection>, withdb.system.name(postgresql,sqlite),db.operation.nameanddb.collection.name. Easy CMS doesn't trace each SQL statement; the instrumentation forpgor libSQL can, if you need that. - Background work:
easy-cms jobs.runfor each run of the jobs, witheasy-cms job <name>for your jobs andeasy-cms outbox <event> <collection>for outbox entries inside.
Spans carry names, not values: collections, routes, operations, status codes and the kind of an
error (error.type, e.g. VALIDATION_ERROR). Never the values of a where, documents, query
strings, emails or user ids.
With JSON logs, each line logged inside a span has its traceId and spanId, next to the
requestId: from a log line you can open its trace.
Metrics
| Metric | Type | Attributes |
|---|---|---|
http.server.request.duration | histogram, seconds | http.request.method, http.route, http.response.status_code |
db.client.operation.duration | histogram, seconds | db.system.name, db.operation.name, db.collection.name |
easy_cms.jobs.runs | counter | job, result (ok, failed) |
easy_cms.jobs.duration | histogram, seconds | job |
easy_cms.queue.size | gauge | queue (outbox, webhooks, emails, scheduled), state (pending, failed) |
easy_cms.scheduled.late | gauge | Scheduled publishes more than 10 minutes late |
The queue gauges are counted when jobs run (every minute, or when your cron calls them), not when
they are read, so a dashboard that refreshes often doesn't add queries. Good alerts to start with:
easy_cms.scheduled.late above 0 (nothing runs the jobs), and easy_cms.queue.size with
state="failed" growing.
Setting up the SDK
Next.js
Next.js loads instrumentation.ts before the app. With @vercel/otel:
npm install @opentelemetry/api @vercel/otel// instrumentation.ts
import { registerOTel } from '@vercel/otel'
export function register() {
registerOTel({ serviceName: 'my-site' })
}Next.js already makes a span for each request, so Easy CMS's spans appear inside it.
Nuxt and easy-cms serve
Load the Node SDK before the server starts, with --import. The repo's
examples/otel has the file:
npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics \
@opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-httpnode --import ./otel.mjs .output/server/index.mjs # Nuxt
node --import ./otel.mjs node_modules/easy-cms/bin/easy-cms.js serveOTEL_EXPORTER_OTLP_ENDPOINT says where to send (default http://localhost:4318), and
OTEL_SERVICE_NAME names the service. Keep one @opentelemetry/api in the app: two copies of
different minor versions may not see each other's SDK.
Try it locally
examples/otel runs an OpenTelemetry Collector that sends traces to Jaeger and metrics to Prometheus:
cd examples/otel
docker compose up -dStart your app with telemetry: true and the SDK, open a few pages, then look at the traces at
http://localhost:16686 and query http_server_request_duration_seconds_count at
http://localhost:9090.