---
title: 'OpenTelemetry'
description: 'How Yorker emits standard OTLP signals and correlates with your existing observability stack.'
section: 'Concepts'
canonical_url: 'https://yorkermonitoring.com/docs/concepts/opentelemetry'
---

# OpenTelemetry

Yorker is OTel-native. Every check emits standard OpenTelemetry signals -- metrics and traces -- using the OTLP HTTP JSON protocol. There is no proprietary telemetry format. If your backend speaks OTel, it works with Yorker.

## Why OTel-native matters

Most synthetic monitoring tools store results in a proprietary system. When something breaks, you switch between your monitoring dashboard and your observability platform, manually correlating timestamps and URLs.

Yorker eliminates that context switch. Your synthetic check results land in the same backend as your application traces, logs, and metrics. A failing health check and the 500 error it triggered share the same trace ID.

## Metrics emitted

Runner-direct OTLP emission (today: browser checks on any location, plus HTTP/MCP checks on private locations that have `OTLP_ENDPOINT` set) produces these metrics as OTLP gauge data points:

| Metric | Type | Description |
|--------|------|-------------|
| `synthetics.http.response_time` | Gauge (ms) | Total response time from request start to last byte received. |
| `synthetics.check.success` | Gauge (0 or 1) | Whether the check passed all assertions. |
| `synthetics.dns.lookup_duration` | Gauge (ms) | Time spent resolving DNS. |
| `synthetics.tls.handshake_duration` | Gauge (ms) | Time spent on TLS handshake. |
| `synthetics.browser.lcp` | Gauge (ms) | Largest Contentful Paint (browser checks only). |
| `synthetics.browser.fcp` | Gauge (ms) | First Contentful Paint (browser checks only). |
| `synthetics.browser.cls` | Gauge (score) | Cumulative Layout Shift (browser checks only). |

These metrics follow OpenTelemetry semantic conventions for synthetic monitoring where they exist, and use the `synthetics.*` namespace for domain-specific signals.

Once you have configured an OTLP endpoint under **Settings > Telemetry (OTLP)**, the control plane outbox path produces matching log events (`synthetics.check.completed`, `synthetics.check.failed`) for every check regardless of type or location. Those log event bodies carry the same response time, status, and timing breakdown as the runner-emitted metrics, so HTTP and MCP checks on Yorker-hosted locations still land observable data in your collector; you just query log events instead of gauges for those. Until you configure an endpoint, no outbox events are enqueued at all.

## Resource attributes

Every metric, trace, and log event includes resource attributes that identify the check, location, and run:

| Attribute | Example | Description |
|-----------|---------|-------------|
| `synthetics.check.id` | `chk_abc123` | Unique check identifier. |
| `synthetics.check.name` | `Homepage` | Human-readable check name. |
| `synthetics.check.type` | `http`, `browser`, or `mcp` | Check type. |
| `synthetics.location.id` | `loc_us_east` | Location identifier. |
| `synthetics.location.name` | `US East (Ashburn)` | Human-readable location name. |
| `synthetics.location.type` | `hosted` or `private` | Whether the location is Yorker-hosted or a private location. |
| `synthetics.run.id` | `run_xyz789` | Unique identifier for this specific execution. |
| `url.full` | `https://example.com` | The URL being monitored. |
| `service.name` | `synthetics` | Service name used by both runner-direct emissions and control-plane outbox events. |

These attributes let you filter, group, and alert on synthetic check data in your observability backend the same way you would with any other OTel-instrumented service.

### Labels as resource attributes

Any labels attached to a check are emitted as additional resource attributes on every metric and trace. This lets you slice telemetry by your own dimensions (environment, service, team, criticality) without having to map check IDs back to metadata in your observability backend.

| Label format | Resource attribute |
|---|---|
| `env:production` | `yorker.label.env="production"` |
| `service:payments` | `yorker.label.service="payments"` |
| `critical` *(no colon)* | `yorker.label.critical="true"` |

See [Create a Monitor: Labels](/docs/guides/create-monitor#labels) for how to attach labels.

## Third-party network attribution

Browser checks emit four additional span attributes on the root `synthetics.check.run` span that describe the third-party network activity observed during the page load. These attributes are emitted whenever the browser executor produced a network summary, which is typical on every completed browser-check run. They are not emitted when the browser executor failed before the network summary was produced (for example, a browser launch failure), and they are not emitted on HTTP checks.

| Attribute | Type | Unit | Description |
|-----------|------|------|-------------|
| `synthetics.third_party.count` | int | requests | Number of network requests classified as third-party (registrable domain differs from the page's). |
| `synthetics.third_party.total_bytes` | int | bytes | Sum of decoded response sizes for all third-party requests in the page load. |
| `synthetics.third_party.domains` | string[] | -- | Unique third-party hostnames observed, capped at 50 entries. Example: `["cdn.tagmanager.net", "fonts.googleapis.com"]`. |
| `synthetics.third_party.domains_truncated` | boolean | -- | `true` when the domains list was capped (the page had more than 50 unique third-party hostnames); `false` otherwise. Use this attribute for deterministic truncation detection. A page can legitimately have exactly 50 unique third-party domains, so `domains.length == 50` alone is not a reliable truncation signal. |

A request is classified as third-party when its registrable domain differs from the top-level page's. Subdomains of the same site are first-party (`cdn.example.com` is first-party to `www.example.com`). Tenants of a shared platform suffix are separate sites (`other.vercel.app` is third-party to `mine.vercel.app`). Hosts without a registrable domain, such as IP addresses, are compared by exact hostname.

When a page load has zero third-party requests all four attributes are still emitted (`count=0`, `total_bytes=0`, `domains=[]`, `domains_truncated=false`) so queries can distinguish "confirmed zero third parties" from "no data" (the attributes are absent on HTTP checks).

## Agent persona signals

HTTP monitors with [agent personas](/docs/guides/monitor-agent-access) switched on repeat their request as AI crawlers and compare each response with the normal one (the baseline). The core metrics and events for the check keep describing the baseline. Persona results are emitted as their own signals so they never skew a response-time chart or an availability query.

### Event

Every persona request produces one `synthetics.agent.persona.checked` log event through the control-plane outbox, for hosted and private locations alike. Severity is `INFO` when the persona matched the baseline and `WARN` when it differed. It carries the check's resource attributes plus:

| Attribute | Type | Description |
|-----------|------|-------------|
| `synthetics.agent.persona` | string | Persona ID, for example `claude-user` or `gptbot`. |
| `synthetics.agent.operator` | string | `Anthropic`, `OpenAI` or `Perplexity`. |
| `synthetics.agent.purpose` | string | `user`, `search` or `training`. |
| `synthetics.agent.divergence` | string | `none` on a match, otherwise `blocked`, `challenged`, `assertion_failed` or `error`. Always present, so you can group by it. |
| `synthetics.agent.divergence_reason` | string | Human-readable reason. Present only on a divergence. |
| `http.response.status_code` | int | Status code the persona received. |
| `synthetics.response_time_ms` | int | Response time of the persona request. |
| `http.response.body.size` | int | Bytes received by the persona. |
| `synthetics.agent.baseline.status_code` | int | Status code the baseline request received. |
| `synthetics.agent.baseline.body_size` | int | Bytes received by the baseline request. |
| `synthetics.run.id`, `synthetics.location.id`, `synthetics.location.name` | string | Same values as the run's `synthetics.check.completed` event. |

All events from one run share the run's trace ID.

### Metrics and spans (runner-direct)

Where the runner emits OTLP directly (today, HTTP checks on private locations with `OTLP_ENDPOINT` set), a persona run also produces:

| Metric | Type | Description |
|--------|------|-------------|
| `synthetics.agent.persona.match` | Gauge (0 or 1) | 1 when the persona matched the baseline. |
| `synthetics.agent.persona.response_time` | Gauge (ms) | Response time of the persona request. |
| `synthetics.agent.persona.status_code` | Gauge | Status code the persona received. |
| `synthetics.agent.persona.body_size` | Gauge (bytes) | Bytes received by the persona. |

Each metric has one data point per persona, carrying `synthetics.agent.persona`, `synthetics.agent.operator`, `synthetics.agent.purpose` and `synthetics.agent.divergence` on top of the usual resource attributes.

The check's trace gains one `synthetics.agent.persona.request` child span per persona under the `synthetics.check.run` root span, with `user_agent.original`, `http.response.status_code` and the persona attributes. A span for a persona that differed has `ERROR` status. The root span carries `synthetics.agent.personas_tested` (count) and `synthetics.agent.personas_diverged` (array of persona IDs).

### Browser monitors running as an agent

A browser monitor with **Run as** set (`agentPersona`) carries `synthetics.agent.persona`, `synthetics.agent.operator` and `synthetics.agent.purpose` as resource attributes on every metric, trace and log it emits, alongside the usual check and location attributes. There is no comparison with a normal run in this case; the monitor's ordinary signals describe the run as that agent.

### Agent view

A browser monitor with [agent view](/docs/guides/monitor-agent-access#browser-monitors-agent-view) on emits three more gauges per run:

| Metric | Type | Description |
|--------|------|-------------|
| `synthetics.agent.view.no_js_text_ratio` | Gauge (0 to 1) | Share of the rendered text that was already in the HTML as served. Not emitted on a run where the HTML could not be read back. |
| `synthetics.agent.view.controls` | Gauge | Visible, enabled buttons, links and form fields on the page the run ended on. |
| `synthetics.agent.view.unnamed_controls` | Gauge | How many of those have no accessible name. |

### What these signals mean

A persona request presents the crawler's user agent from a Yorker location. It is not signed by the operator and does not come from the operator's IP ranges, so it shows how your stack treats that user agent, not how a CDN that verifies bot identity treats the genuine crawler.

## Agent readiness signals

HTTP monitors with [agent readiness](/docs/guides/agent-readiness) switched on probe their own site for robots.txt, llms.txt, a Markdown version of the page and discovery files.

### Event

Each readiness run produces one `synthetics.agent.readiness.checked` log event through the control-plane outbox, for hosted and private locations alike. Severity is `WARN` when any probe fails or regressed, otherwise `INFO`. It carries the check's resource attributes plus:

| Attribute | Type | Description |
|-----------|------|-------------|
| `synthetics.agent.readiness.score` | int | Passed probes as a percentage of those evaluated, 0 to 100. |
| `synthetics.agent.readiness.passed_probes` | string[] | IDs of probes that passed. |
| `synthetics.agent.readiness.failed_probes` | string[] | IDs of probes that were present but wrong. |
| `synthetics.agent.readiness.missing_probes` | string[] | IDs of probes for things the site does not serve. |
| `synthetics.agent.readiness.regressed_probes` | string[] | Probes that passed on the previous readiness run from this location and no longer do. |
| `synthetics.agent.readiness.improved_probes` | string[] | Probes that did not pass on that previous run and now do. |
| `synthetics.run.id`, `synthetics.location.id`, `synthetics.location.name` | string | Same values as the run's `synthetics.check.completed` event. |

Changes are computed per location because some of what is probed can legitimately differ by region. The previous run is looked up within the last 7 days; with none in that window both change lists are empty. Probes that were not checked on either run are left out of the comparison.

Probe IDs are listed in the [guide](/docs/guides/agent-readiness#what-gets-checked).

### Metric and span attributes (runner-direct)

Where the runner emits OTLP directly (today, HTTP checks on private locations with `OTLP_ENDPOINT` set), a readiness run also produces a `synthetics.agent.readiness.score` gauge, and the check's `synthetics.check.run` root span carries `synthetics.agent.readiness.score` and `synthetics.agent.readiness.failed_probes`.

## Cross-monitor correlation events

When two or more **browser** monitors in a team fail within a 5-minute window and each had failing requests to the same third-party dependency, Yorker emits one `synthetics.correlation.detected` OTLP log event per distinct correlation slice. A request is failing when it returned a 4xx or 5xx status or never got a response. Requests the browser cancelled or blocked itself do not count unless an error status had already come back. The event is co-occurrence-only: it proves requests to the same dependency failed in multiple failing runs, not that the dependency caused the failures.

The event fires for browser monitors only. HTTP and MCP checks do not populate the network-summary data the detection reads, so no correlation event is emitted for those check types. If a team has only HTTP or MCP checks, this event will never fire.

### Attributes

| Attribute | Type | Description |
|-----------|------|-------------|
| `synthetics.correlation.affected_monitors` | string[] | Check IDs identified in the scan as failing with a failing request to the shared dependency. Includes the triggering check. Sorted lexicographically. The array is capped at 50 entries to keep OTLP payloads under per-attribute size limits enforced by some backends; use `affected_monitors_truncated` to detect when the cap was hit. The underlying detection scan is itself bounded by 200 candidate rows per ingest. |
| `synthetics.correlation.affected_monitors_truncated` | boolean | `true` when the emitted `affected_monitors` array was capped (more than 50 distinct check IDs were identified); `false` otherwise. When `true`, treat `affected_count` (not `affected_monitors.length`) as the authoritative cardinality. |
| `synthetics.correlation.affected_count` | int | Number of distinct monitors identified in the scan. Always >= 2 for the event to fire. Authoritative cardinality, unaffected by the `affected_monitors` cap. The detection scan is bounded by 200 candidate rows per ingest, so under exceptionally high failure volume `affected_count` itself may be a lower bound on the full set. |
| `synthetics.correlation.shared_dependency` | string | The third-party domain that failed in the most distinct affected check IDs among domains that also failed in the triggering result. |
| `event.name` | string | Always `synthetics.correlation.detected`. |

The standard resource attributes (`synthetics.check.id`, `synthetics.check.name`, `synthetics.check.type`, `synthetics.location.id`, `synthetics.location.name`, `synthetics.run.id`, plus any user labels) come from the triggering check that produced the event.

### Threshold and dedup contract

The event fires when, for a triggering failing result with at least one failing third-party domain, two or more distinct check IDs (including the triggering one) within the 5-minute window have failed AND had a failing request to at least one of the triggering result's failing domains. A domain that both pages loaded successfully does not count.

The scan considers at most 50 of the triggering result's failing domains. Results from runners that predate failing-domain reporting (older private-location agents) take part using HTTP error statuses only; network-level failures such as refused connections are counted once the agent is upgraded.

The window is `[triggering result's DB-stamped created_at - 5 min, triggering result's created_at]`, inclusive on both ends. Anchored to DB timestamps, not wall clock.

Dedup is keyed by the tuple `(team, shared_dependency, affected_monitor_set)`. A sustained outage that keeps the same set of monitors failing produces one event per window for that combination. If the affected-monitor set changes (a new monitor joins the correlation, or a recovering monitor drops out) the combination key changes and a new event is emitted for the updated slice. This is the intended behaviour: the event represents a specific co-occurrence slice, and a different slice deserves its own event.

### What this is and is not

The event is a co-occurrence signal. It indicates "these N monitors all failed within 5 minutes and requests to this third-party domain failed in each of them." It is **not** root-cause attribution: the third-party may itself be a victim of a deeper outage your monitors cannot observe, or the failures may be coincidental and the shared domain irrelevant. Investigate before paging the vendor.

If you want fine-grained correlation evidence, the alerting UI already provides it (per-incident hypothesis, scoped dimensions). The OTel event is for getting the same signal into your observability backend without polling Yorker's API.

## Trace correlation

Yorker injects a W3C `traceparent` header into outbound requests during check execution. This is how it works:

1. The runner generates a trace ID for the check execution.
2. The `traceparent` header is added to the HTTP request (or injected into the browser's network requests for browser checks).
3. Your backend application picks up the trace context via its own OTel instrumentation.
4. The synthetic check span and your backend request span share the same distributed trace.

The result: when a check fails, you can click from the Yorker alert directly to the distributed trace in your observability backend. You see the synthetic request, the backend handler, the database query, and the error -- in one view.

## Backend compatibility

Yorker works with any OTel-compatible backend. All Yorker telemetry is emitted as OTLP HTTP JSON, the most widely supported OTel transport. Tested backends include:

- **ClickStack** (ClickHouse + HyperDX)
- **Grafana Cloud** (Tempo + Mimir)
- **Datadog**
- **Honeycomb**
- **New Relic**
- **Jaeger**
- **Any OTLP-compatible collector** (OpenTelemetry Collector, Alloy, Vector)

## Emission model

Yorker has **two OTel emitters**, and they both target the same `otlpEndpoint` you configure on your team:

- **Runners** emit OTLP metrics, traces, and logs directly to your collector, but only for browser checks today. Hosted HTTP and MCP runners do not emit OTLP from the runner process; private-location operators can enable runner-direct emission by setting `OTLP_ENDPOINT`/`OTLP_API_KEY` on their runner container when they start it.
- **The orchestrator** drains an emission outbox that the control plane writes to, and ships every OTel log/span event Yorker generates to your collector, including `synthetics.check.completed`, `synthetics.check.failed`, `synthetics.step.completed`, alert state changes, SLO burn warnings, TLS certificate events, monitor/team insights, deployment markers, and maintenance-window events. The control plane enqueues; the orchestrator polls the outbox every ~10 seconds, runs SSRF guards, and POSTs.

The metrics catalogued above (`synthetics.http.response_time`, `synthetics.check.success`, and friends) are currently produced by the runner-direct path, which means you will see them for browser checks. For HTTP and MCP checks on hosted locations, the same information reaches your collector via the `synthetics.check.completed` / `check.failed` log events: they carry `responseTimeMs`, `status`, assertion results, timing breakdown, and the same resource attributes as the metrics, so dashboards and queries can key off either signal.

See the [Telemetry flow section in Architecture](/docs/concepts/architecture#telemetry-flow) for the full table of which check type and location combinations take which path.

## Setup

To configure OTel emission for your team:

1. Go to **Settings > Telemetry (OTLP)** in the Yorker dashboard.
2. Enter your OTLP endpoint URL (e.g., `https://otel-collector.example.com:4318`).
3. Enter your OTLP API key. The value is sent **verbatim as the `Authorization` header** on every OTLP request, so for a backend that expects a Bearer token enter the full `Bearer <token>`, not just the token. ClickStack expects the ingestion key on its own; see [Connect ClickStack / HyperDX](/docs/guides/connect-clickstack).
4. If your backend needs a header other than `Authorization` (or more than one header), add them under **Custom OTLP Headers**. This lets you export directly to backends like Honeycomb (`x-honeycomb-team`), Datadog (`dd-api-key`), New Relic (`api-key`), or SigNoz Cloud (`signoz-ingestion-key`), without an OTel Collector in between. A custom header always wins over the OTLP API Key above if the names collide. Header values are encrypted and never shown again after save; use the row's value field to replace one, or remove the row to delete it.
5. Click **Test Connection**. Yorker's control plane dispatches a test payload and reports success or failure.
6. Save.

From this point, the control plane starts enqueueing events for the orchestrator to ship (for every check type and every location), and browser-check runners start including the endpoint (and any custom headers) in each execution payload for runner-direct metric/trace emission. Team-level OTLP credentials, including custom headers, are stored on the team, not per-check.

Private-location agents configure the same custom headers via environment variables instead of the dashboard: `OTLP_API_KEY` for the legacy Authorization value, plus the standard `OTEL_EXPORTER_OTLP_HEADERS` env var (comma-separated `key=value` pairs, values URL-encoded) for everything else. See [Private Locations](/docs/guides/private-locations#direct-otlp-export) for the full syntax and precedence rules.
