Metrics
Push counters and histograms about business events to Dash0 so we can chart, alert, and slice them by attributes — without inflating trace volume or grepping logs.
This page covers how to add a metric correctly. The OTel SDK is already wired in @repo/tracer; you just import and use it.
When to use metrics (vs traces vs logs)
| Signal | Question it answers | Example |
|---|---|---|
| Metric | ”How often?” / “How much?” / “How fast?” | Orders placed per hour, by marketplace |
| Trace | ”What happened in this specific request?” | Why did this checkout take 8 seconds? |
| Log | ”What does this specific event look like?” | The exact error message for order #abc123 |
If your question is aggregate (counts, rates, distributions) → metric. If it’s about a specific occurrence → trace or log.
⚠️ The cardinality trap — read before adding a metric
Dash0 bills per unique (metric name, attribute set) time series, at roughly $0.20 per 1M datapoints. That’s cheap if you keep cardinality bounded.
{ game: "pokemon", action: "create" }— 5 games × 3 actions = 15 series ✓{ userId: "abc123" }— millions of users = millions of series ✗ ($200+/mo and unusable charts)
Attribute values MUST be enums or bounded categorical sets. Things like user IDs, order IDs, session IDs, IP addresses, free-text — those belong on traces or logs, never metrics. If you’re not sure how many distinct values an attribute can take, don’t add it.
The API
Three things to know.
import { metrics } from "@repo/tracer"
// 1. Get a meter — one per domain/package, conventionally the package name.
const meter = metrics.getMeter("@repo/inventory")
// 2. Declare counters as module-level constants (NOT inside handlers — would
// re-register on every call).
const inventoryMutations = meter.createCounter("inventory.mutations", {
description: "Inventory create/update/delete operations",
})
// 3. Call .add() at every event site.
inventoryMutations.add(1, {
game: gameSlug, // pokemon / mtg / lorcana / ...
action: "create", // create / update / delete
source: "csv-import" // ui / csv-import / api / admin
})Counter types
| Method | Use for | Example |
|---|---|---|
meter.createCounter(name, opts) | Monotonic count of events | orders.placed, users.signups |
meter.createUpDownCounter(name, opts) | Quantity that goes up AND down | Queue depth, active sessions |
meter.createHistogram(name, opts) | Distribution of values (latency, size) | Request latency, payload bytes |
Most cases are counters. Reach for histograms when you need percentiles (p50/p95/p99), and up-down counters when the value can decrement.
Naming conventions
Lowercase, dotted, scoped to a domain. Match how you’d want to filter in Dash0.
| Name | Type | Description |
|---|---|---|
inventory.mutations | Counter | Inventory create/update/delete operations |
orders.placed | Counter | Successful order checkouts |
users.signups | Counter | Account creations |
users.logins | Counter | Successful logins |
auth.failed_logins | Counter | Failed login attempts (distinct metric — different question) |
orders.fulfillment.duration_ms | Histogram | Time from placed → shipped |
Rules:
<domain>.<event>or<domain>.<sub>.<event>- Plural events (
signups, notsignup). - Include unit in the name only when ambiguous (
.duration_ms,.bytes). Counters of events don’t need a unit suffix. - Errors and successes go in separate metrics, not one metric with a
success: true/falseattribute. The question “how many failures” is independent of “how many successes” and you’ll want to alert on them differently.
Worked example — “user signs up”
Before
// packages/backend/user/src/auth/signup.service.ts
@injectable()
export class SignupService {
async signup(input: SignupInput): Promise<Result<User, SignupError>> {
// ... validate, create user, send welcome email ...
return ok(user)
}
}After
// packages/backend/user/src/auth/signup.service.ts
import { metrics } from "@repo/tracer"
// Module-level — registered once when the file loads.
const meter = metrics.getMeter("@repo/user")
const signupsCounter = meter.createCounter("users.signups", {
description: "Successful account creations",
})
@injectable()
export class SignupService {
async signup(input: SignupInput): Promise<Result<User, SignupError>> {
// ... validate, create user, send welcome email ...
signupsCounter.add(1, {
source: input.source, // "web" | "mobile" | "discord-oauth"
country: input.country, // ISO country code (~250 values — fine)
})
return ok(user)
}
}Two attributes, both bounded enums. Total cardinality: ~3 sources Ă— ~250 countries = ~750 series. Cheap and useful.
If you wanted to track signup failures, that’s a separate counter (users.signup_failures) with attributes like { reason: "email_taken" | "invalid_email" | "rate_limited" } — not a success: false flag on the same counter.
Where to view in Dash0
In Dash0, switch to the dataset that matches your environment (dev / staging / prod), then go to the Metrics explorer. Filter by:
service.name = "repo/backend"(or"repo/public-api")- The metric name (e.g.
users.signups)
Group by attributes (e.g. source, country) to slice the chart. The standard resource attributes (deployment.environment.name, aws.ecs.task.arn, cloud.region) are attached automatically by the OTel SDK + Collector — no need to set them yourself on the counter.
Local development
Metrics export honors OTEL_SDK_DISABLED=true — same flag that skips the tracer. If you want metrics to flow in local dev:
- Set
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 - Run a local OTel Collector pointed at Dash0 (or use the
debugexporter to dump metrics to stdout)
Most engineers don’t need this — verify the counter is wired correctly with a unit test or by deploying to dev and checking Dash0.
Adding a new metric — checklist
Pick the name
Lowercase, dotted, scoped to a domain. See Naming conventions.
Pick the attributes (and verify cardinality)
Each attribute should be an enum or bounded set. Multiply the cardinalities together — if you’re under ~1000 series for the metric, you’re fine. Above that, drop or coarsen attributes.
Declare the counter as a module-level constant
const fooCounter = meter.createCounter(...) at the top of the file, NOT inside the function that calls it.
Call .add() at every event site
Pass 1 (or the relevant value for histograms) and the attribute object. Keep attribute keys identical across all call sites for the same metric — different keys produce different series.
Verify in Dash0 after deploy
Within ~60 seconds of the first event after deploy (the SDK exports on a 60s interval), the metric should appear in the Dash0 Metrics explorer under the matching dataset.
Code Locations
| Component | Location |
|---|---|
metrics API re-export | packages/backend/tracer/src/index.ts |
MeterProvider + OTLP exporter wiring | packages/backend/tracer/src/lib/tracer.ts |
| Collector pipeline that ships metrics to Dash0 | specs/dash0/infra.md (§3.3 — the YAML in aws_ssm_parameter.otel_collector_config) |