# OpenTelemetry Visibility

Export decision spans and bounded metrics with deep links to exact evaluation records.

<!-- docs-source -->
Source: https://learning.monetizekit.app/docs/guides/opentelemetry
<!-- /docs-source -->

Entitlement checks are control-flow: they decide whether requests proceed, degrade, or get billed. When they misbehave — slow, degraded, or suddenly denying — you want to see it in the same dashboards and traces as the rest of your system, not in a separate silo. The packaged exporter registers instrumentation against your existing OpenTelemetry setup; it composes with any OTLP-speaking vendor and no-ops silently when no OTel SDK is registered.

> [!NOTE] Requires @monetizekit/node >= 0.2.0
>
> The exporter ships as the `@monetizekit/node/otel` subpath export, built on the `DecisionObserver` extension point — see the [Node SDK guide](/docs/guides/sdk-node). `@opentelemetry/api` is an optional peer dependency, loaded only by this subpath. MonetizeKit does not push OTLP telemetry from the platform side; the signals below are generated in your process, where the decisions are consumed.

## Enable in one line

`instrumentMonetizeKit()` returns a `DecisionObserver` that emits spans and metrics through your process's registered tracer and meter providers:

```typescript
import { MonetizeKit } from "@monetizekit/node";
import { instrumentMonetizeKit } from "@monetizekit/node/otel";

const apiKey = process.env.MONETIZEKIT_API_KEY;
if (!apiKey) throw new Error("MONETIZEKIT_API_KEY is required");

export const mk = new MonetizeKit({
  apiKey,
  observers: [instrumentMonetizeKit()],
});
```

Sampling is parent-based and never overridden: if the caller's trace is unsampled, the decision span is too. Observer failures are swallowed twice over (by the SDK and by the exporter itself) — a broken telemetry pipeline can never affect an entitlement decision.

## What gets emitted

One span per decision — `monetizekit.entitlement_check` for single checks, `monetizekit.batch_check` for batch results — nested inside whatever span is active when the check runs. Names and attributes are stable public API: renames are treated as breaking changes and ship with migration notes.

| Span attribute | Meaning |
| --- | --- |
| `monetizekit.customer_id` | MonetizeKit customer id the decision was made for. |
| `monetizekit.feature_key` | Feature key that was checked. |
| `monetizekit.decision` | “allowed” or “denied” — the product decision. |
| `monetizekit.reason_code` | Stable machine-readable cause (granted, limit_reached, not_in_plan, …). |
| `monetizekit.cache_hit` | True when served from the SDK's local cache. Cached decisions are real spans too — flagged, not hidden. |
| `monetizekit.degraded` | True when the API was unreachable and the SDK's degradation policy answered. |
| `monetizekit.evaluation_id` | Id of the evaluation-log record behind the decision (when the API supplied one). |
| `monetizekit.inspector_url` | Ready-made dashboard deep link to that exact evaluation record. |
| `monetizekit.error` | The transport error message, on API failures only. |

> [!WARNING] A denial is not an error
>
> Denied checks are span status `OK` with `monetizekit.decision=denied` — a customer hitting a plan limit is the product working, and it will never pollute your error tracker. Only transport failures (API unreachable, timeout) set status `ERROR`; degraded decisions additionally carry `monetizekit.degraded=true` so you can alert on degradation separately from denials.

## Metrics: bounded by design

Three instruments cover the operational questions. Metric attributes carry decision, cache, degradation, and feature-key dimensions — and deliberately never customer or evaluation ids, so time-series cardinality (and your monitoring bill) cannot grow with your account count:

| Instrument | Kind | Meaning |
| --- | --- | --- |
| `monetizekit.check.duration` | Histogram (ms) | End-to-end check duration — cache hits and API round trips, distinguishable by attribute. |
| `monetizekit.checks` | Counter | Decision volume, dimensioned by decision, cache_hit, degraded, and feature_key. |
| `monetizekit.degraded_checks` | Counter | Decisions served by the fail-open/fail-closed fallback. Alert when this is nonzero. |

Customer-level detail belongs on spans and in the [analytics stream](/docs/guides/posthog-analytics), not in metrics. Traces and metrics are independently enableable via `instrumentMonetizeKit({ traces, metrics })`.

## From a span to the exact evaluation

Every check response carries the id of its evaluation-log record as `evaluationId`, and the exporter renders it as a ready-made deep link: `monetizekit.inspector_url` points at `/observability/inspector/{evaluationId}` in the dashboard, which resolves that one record directly — plan version, resolution reason, usage context, evaluation timing. No searching. A trace showing a denial becomes the evaluation record explaining it, one click apart.

| Tool | Rendering the link |
| --- | --- |
| Datadog | Facet the monetizekit.inspector_url span attribute; URL-valued facets render as clickable links in the trace side panel. |
| Grafana (Tempo) | Add a data link on the span attribute using ${__data.fields["monetizekit.inspector_url"]} in the trace panel's Data links settings. |
| Honeycomb | URL-shaped column values are clickable in trace and query results out of the box — no configuration needed. |
| Sentry | Span attributes render in the trace detail view; URL values are shown as links in the span attributes table. |

> [!NOTE] Evaluation records expire after 90 days
>
> Evaluation logs are retained for 90 days, globally. An inspector URL stored in a trace remains a valid correlation key in your own telemetry forever, but it stops resolving to a record after that window — investigate recent evaluations, don't archive by inspector URL. The link itself grants nothing: opening it requires signing in to a workspace that owns the evaluation.

## Alerts worth paging on

Express these in your metrics backend of choice — the instrument and attribute names are the ones emitted above:

| Alert | Expression | What it means |
| --- | --- | --- |
| Degraded decisions | `rate of monetizekit.degraded_checks > 0` | The SDK cannot reach the API and is serving fallback decisions — your gates are running on guesses. |
| Fallback share | `decisions with monetizekit.reason_code in (sdk_fail_open, sdk_fail_closed) as a % of all decisions` | How much of your traffic was decided by the fail-open/fail-closed policy, not the API. |
| Gate latency | `p95 of monetizekit.check.duration where monetizekit.cache_hit = false, per feature_key` | Uncached checks sit on your hot path — a p95 regression here is user-visible request latency. |
| Denial spike | `sudden change in monetizekit.checks where monetizekit.decision = denied, per feature_key` | A plan publish or catalog change may have unintentionally revoked access — correlate with your deploy/config timeline. |

## Manual pattern: span events instead of spans

If you'd rather attach decisions to your existing request spans than create a child span per check, implement `DecisionObserver` directly — the packaged exporter and this pattern use the same extension point and the same attribute names:

```typescript
import { trace } from "@opentelemetry/api";
import { MonetizeKit, type DecisionObserver } from "@monetizekit/node";

const spanEventObserver: DecisionObserver = {
  onDecision(event) {
    trace.getActiveSpan()?.addEvent("monetizekit.decision", {
      "monetizekit.feature_key": event.featureKey ?? "",
      "monetizekit.decision": event.allowed ? "allowed" : "denied",
      "monetizekit.reason_code": event.reasonCode ?? "",
      "monetizekit.cache_hit": event.cached,
      "monetizekit.degraded": event.degraded,
      "monetizekit.latency_ms": event.latencyMs,
    });
  },
};

export const mk = new MonetizeKit({
  apiKey: process.env.MONETIZEKIT_API_KEY,
  observers: [spanEventObserver],
});
```

## FAQ

### Do I need a specific OTel vendor or collector?

No. The exporter depends only on `@opentelemetry/api`, which delegates to whatever SDK your process registered — any OTLP-speaking backend works. Without a registered SDK the calls are no-ops with no warnings, so the observer is safe to ship before your telemetry stack is wired up.

### Will this flood my tracing bill with cache-hit spans?

Cache-served decisions are emitted as spans flagged `monetizekit.cache_hit=true`, because a decision your code acted on that is invisible in the trace is a debugging trap. Sampling is parent-based: decision spans are only recorded inside traces you already chose to sample. If you still want fewer spans, use the manual span-event pattern, or disable traces and keep metrics.

### Can I use both this and the PostHog destination?

Yes — they answer different questions from different vantage points. OTel telemetry is generated in your process and tells you whether gating is *healthy* (latency, degradation, error rates); the PostHog destination streams denials from the platform side and tells you whether packaging is *working* (who hits limits, what converts). Neither depends on the other.

### What do degraded spans look like during an outage?

Span status `ERROR` (the API call failed), with `monetizekit.degraded=true`, `monetizekit.error` carrying the transport message, and `monetizekit.reason_code` set to `sdk_fail_open` or `sdk_fail_closed` (or a stale-cache reason when the SDK could serve real, older data). One alert on `monetizekit.degraded_checks` distinguishes “the API is down” from “customers are hitting limits” — which is the point.

## Related guides

### Node SDK Integration

The DecisionObserver extension point this guide builds on.

[Open guide](/docs/guides/sdk-node)

### Audit and Trace

Request-id propagation and the audit log, end to end.

[Open guide](/docs/guides/audit-trace)

### PostHog Packaging Analytics

The product-analytics twin of this guide's operational signals.

[Open guide](/docs/guides/posthog-analytics)