Golden Astronaut in Depth · The OpenTelemetry Data Model

The OpenTelemetry Data Model

The OTCA blueprint gives you the Resource/InstrumentationScope/record shape in a sentence, under the smallest of the exam's four domains, and moves on to the SDK pipeline where the marks actually are. That's the right amount of depth for an associate exam. It's the wrong amount for a platform team that will spend years running the shared telemetry fleet every other team's dashboards depend on. This page goes underneath the exam: what a Resource actually is and why "the process," not "the request," is its natural unit; why InstrumentationScope exists as its own layer instead of being folded into Resource or the record; what OTLP genuinely looks like as bytes on a wire, and why that specific choice — not just goodwill — is what makes "swap your backend without re-instrumenting" a true sentence instead of a vendor's slogan; why the API's no-op default is an organizational lever a platform team can pull, not merely a technical curiosity; and how schema URLs let semantic conventions evolve without a flag-day rename across a fleet you don't fully control. None of this is asked on the OTCA. All of it is what separates knowing the vocabulary from being the person a platform team trusts to own the Collector config that every other team's telemetry passes through.

☺ Explain it like I'm 10

Imagine every employee at a huge company has to fill in a timesheet, and the timesheet has three sections that can never get mixed up. Section one is stapled once, at the top, for the whole page: which office you work in, which team, which building. Section two is a smaller stamp partway down: which specific stapler you used to staple it — brand, model, the exact tool that did the work. Section three is the actual line for today: what you did, from when to when. You'd never write "I work in the London office" on every single line of the day, and you'd never staple a new office address onto every task — the office goes at the top, once, because it describes the whole page, not any one task on it. OpenTelemetry's data model is exactly this timesheet: a Resource (the office, stapled once per process), an InstrumentationScope (the stapler, one per library that did the writing), and the actual line of work underneath. Mixing them up is the single most common way a telemetry pipeline gets confusing to query later.

🐘Your host for this topic: Ellie the Elephant — she already carries the Prometheus model and every OTCA lesson on this course. This is her going underneath the exam, into the shape of the data itself and why a platform team should care about a sandwich most engineers never read past the summary of.

Why a data model deserves its own page

☺ Like you're 10: Knowing the shape of the paperwork isn't the interesting part. Knowing why the paperwork is shaped that way — so a whole company's worth of timesheets stay comparable — is.

Every certification page on the OTCA track, including this course's own, teaches the Resource/InstrumentationScope/record split as a fact to memorize: three layers, learn their names, move to the SDK pipeline where most of the marks live. That's correct exam strategy — the "Fundamentals of Observability" domain is worth 18% next to the SDK's 46%, so a candidate should not over-invest there. But a platform team doesn't get graded on a multiple-choice paper. It gets graded, informally and constantly, on whether the shared telemetry pipeline it runs stays queryable, attributable and debuggable as forty teams' worth of services pour spans, metrics and logs through it. That job lives almost entirely inside the layer the exam treats as a warm-up.

Three questions separate "I can name the three layers" from "I can run this in production": Why does the model split what produced this from which library emitted this instead of flattening both into tags on the record? Why is the boundary between the API and the SDK drawn exactly where it is, and what does that boundary let a platform team do that it otherwise couldn't? And what actually happens on the wire — and in a Collector's config file two years from now — when the semantic conventions this all rests on inevitably change underneath you? This page answers all three.

The sandwich, layer by layer

☺ Like you're 10: Office stamped once at the top. Stapler noted once per stapling. The actual work, written fresh every single line. Three layers, three different lifetimes.

Every signal OpenTelemetry produces — a trace's Span, a metric's data point, a log's LogRecord — is wrapped in the same two outer layers before it's a record of anything at all:

LayerAnswersSet byLifetimeExample attributes
ResourceWhat produced this telemetry?The SDK, once, at process start-up — resource detectors plus whatever you set explicitlyThe life of the process (or, for something like a serverless function, the life of the instance)service.name, service.version, service.instance.id, k8s.pod.name, k8s.namespace.name, cloud.region, host.arch
InstrumentationScopeWhich library emitted this specific record?The instrumentation library itself, at the moment it calls the API to start a span or record a pointPer call site — one process can (and usually does) hold dozens of scopes at oncename (e.g. io.opentelemetry.contrib.otelhttp), version (the library's own version, not your service's), a small set of scope attributes
The recordWhat actually happened, this one time?Application or library code, on every single callOne event — one request, one data point, one log lineA span's trace_id/span_id/attributes/events; a metric's value and its own data-point attributes; a log's severity, body and trace-context fields

Nest them and you get the actual wire shape: one Resource wraps many InstrumentationScopes, and each Scope wraps many records. A single checkout service, in a single process, might hold one Resource (itself) and a dozen Scopes at once — the framework's HTTP server instrumentation, its database driver's instrumentation, the gRPC client library, and your own hand-written tracer.start_span(...) calls each register as their own Scope, because each is a different piece of code with its own name and its own version, even though every one of them is describing work done by the exact same process.

RESOURCE — set once, for the whole process service.name: checkout service.version: 2.3.0 k8s.pod.name: checkout-7f9d4 cloud.region: ap-south-1 INSTRUMENTATIONSCOPE io.opentelemetry.contrib.otelhttp version 0.49.0 THE RECORD — a Span name: POST /checkout trace_id / span_id (per call) http.route: /checkout http.response.status_code: 200 new record, every request INSTRUMENTATIONSCOPE checkout.pricing (your own code) version 1.0.0 THE RECORD — a Span name: apply_discount_rules discount.rules_evaluated: 4 discount.amount_cents: 1200 a second scope, same process — your business logic, not a library one Resource, many Scopes, many records per Scope
⚠ Watch out

The most common data-model mistake isn't about the layers — it's about which layer something belongs in. A Resource is not the place for anything that changes per request. customer.tier, a request ID, a cart total — none of these describe the process; they describe one event, and they belong as record-level attributes. Stuff them into a Resource instead (which some early OpenTelemetry adopters did, coming from tag-everything metrics systems) and every span your process ever emits for its entire lifetime carries that one request's data frozen onto it, because a Resource is created once and reused for every record after. Symmetrically, don't push things that genuinely describe the whole process — service.version, k8s.pod.name — down onto individual spans "just to be safe": every backend that understands the model treats Resource attributes as filterable at the process level for free, and duplicating them onto every record wastes bytes on the wire for no benefit.

Resource: the layer a platform team actually owns

☺ Like you're 10: Nobody wants five hundred different teams each deciding, on their own, what "which office do I work in" means on their timesheet. Someone stamps that part centrally, once, correctly.

A Resource is built from two sources that get merged: resource detectors, which run automatically and read the environment — the Kubernetes downward API, cloud-provider metadata endpoints, the process's own runtime — and explicit attributes, which you or the platform set directly in code or via the OTEL_RESOURCE_ATTRIBUTES environment variable. The practical question that actually bites teams is what happens when both sides try to set the same key.

# Set centrally, once, by the platform — via the OpenTelemetry Operator's
# Instrumentation CRD or a Deployment's env, never hand-typed per service:
OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=prod,team.owner=checkout-guild"
OTEL_SERVICE_NAME=checkout

# A resource detector then ADDS to this from the environment it's running in —
# it does not get to overrule what was set explicitly on purpose:
#   k8s.pod.name, k8s.namespace.name, k8s.node.name   (from the k8s detector)
#   cloud.provider, cloud.region, cloud.availability_zone  (from the cloud detector)
#   host.arch, os.type                                 (from the process/host detector)

The exact precedence rule for a colliding key differs in its fine print across language SDKs — check your own before relying on ordering — but the operational discipline that works everywhere is the same: decide explicitly which values are the platform's to set and which are the service team's, and never let the answer depend on which resource detector happened to register first. A platform team that centralizes deployment.environment.name, k8s.*, and cloud.* through the Collector's own resourcedetection and k8sattributes processors — rather than trusting forty services to each configure their own SDK-side detection consistently — gets one guarantee that matters far more than any single attribute's correctness: every service's telemetry is filterable by environment and cluster the same way, because one piece of config decided it, not forty independent ones that will inevitably drift.

Why the API/SDK split is a platform-team lever, not app-dev trivia

☺ Like you're 10: If the rulebook for "how to write on the timesheet" never changes, you can swap out the entire back-office filing system behind it without a single employee needing to learn anything new.

The OTCA teaches the API/SDK split as a fact worth remembering: the API is what instrumentation calls, it's a no-op until an SDK registers behind it, and forgetting to register one is why "I added spans and nothing showed up" is the single most common first bug. All true, and all aimed at recognizing that failure mode on a multiple-choice question. What the exam doesn't ask — because it's an organizational argument, not a technical one — is why OpenTelemetry drew that particular line, and what it buys the team that owns the SDK's other side.

The API has been stable since OpenTelemetry's tracing and metrics specifications hit 1.0: the method signatures a library calls to start a span or record a measurement do not change across SDK versions, do not change across which exporter you're sending to, and do not change whether you're shipping to a self-hosted Jaeger or a commercial SaaS vendor. That stability is the entire point, and it draws an organizational boundary as cleanly as it draws a technical one:

◆ Key idea

Most engineers meet the API/SDK split as a debugging fact: no SDK registered, no telemetry. The version worth internalizing as a platform engineer is the organizational one: a stable, no-op-by-default API is what lets a platform team own an evolving, opinionated SDK configuration without ever asking an application team to change a line of instrumented code. That's not a side effect of the design — for a project explicitly built to outlive any one backend, it's the design.

API app team's surface tracer.start_span(...) span.set_attribute(...) stable since spec 1.0 no-op until an SDK is registered behind it platform-team boundary starts here SDK platform team's surface sampler · processors exporter · resource set via env vars or an Instrumentation CRD — zero app-team PRs Collector platform team's surface receivers · processors exporters · fleet policy a standalone process, not a library — sampling and redaction live here same instrumented code, forever — everything right of the line can change without it

OTLP: what "vendor-neutral" actually means on the wire

☺ Like you're 10: It's not just a friendly agreement to play nice. There's an actual shared shipping format, and that format is the whole reason you can send the same package to two different warehouses without repacking it.

"Vendor-neutral" gets said so often about OpenTelemetry that it's easy to hear it as marketing rather than as a specific, checkable technical property. It's checkable: OTLP (the OpenTelemetry Protocol) defines the exact message shape that carries the sandwich above across the wire, as Protocol Buffers, over gRPC or HTTP. Every OTLP-compliant backend — a Collector, Jaeger, Grafana Tempo, a vendor's ingest endpoint — parses the identical byte layout. Here is that shape, simplified, for the trace signal:

// The top-level envelope an OTLP export request carries — this is
// what actually travels the wire, one ResourceSpans per process.
message ResourceSpans {
  Resource resource = 1;              // the outer layer: set once
  repeated ScopeSpans scope_spans = 2; // many scopes per resource
  string schema_url = 3;               // which semantic-convention version
}

message ScopeSpans {
  InstrumentationScope scope = 1;      // the middle layer: one library
  repeated Span spans = 2;             // many records per scope
  string schema_url = 3;               // scope-level schema, can differ
}                                       // from the resource's if a library
                                        // instruments to an older version

message Resource {
  repeated KeyValue attributes = 1;    // service.name, k8s.pod.name, ...
}

message InstrumentationScope {
  string name = 1;                     // "io.opentelemetry.contrib.otelhttp"
  string version = 2;                  // the LIBRARY's version, not yours
  repeated KeyValue attributes = 3;
}

That nesting is not incidental — it's the sandwich from the diagram above, serialized exactly as drawn: one Resource per envelope, wrapping a list of ScopeSpans, each wrapping a list of actual Span records. Because this shape is a public protobuf schema rather than any one vendor's internal format, a service can export the identical bytes to two destinations at once — a Collector configured with two exporters, fanning the same OTLP payload to a self-hosted Tempo instance and a commercial SaaS backend simultaneously, during a migration, with neither destination aware the other exists. That's the concrete, checkable version of "avoid vendor lock-in": not a promise, a byte-for-byte fact about what left the process.

🐘 Ellie's workshop · 15 min

On a machine with the Collector available, set up a receiver on otlp and two exporters — debug at verbosity: detailed and a second otlp exporter pointed at a second, throwaway Collector instance listening on a different port. Wire both into the same traces pipeline and send a few spans with telemetrygen. Watch the exact same ResourceSpans — same Resource, same Scope, same Span — printed by the debug exporter and arrive intact at the second Collector. You've just watched the "vendor-neutral" claim happen as bytes, not as a sentence in a README.

Schema URLs: evolving the vocabulary without a flag day

☺ Like you're 10: When the rulebook changes a word's spelling, you don't want every old form thrown out — you want a translator who knows both spellings mean the same thing.

Semantic conventions — the agreed attribute names like http.request.method — are not frozen. They've been renamed before: http.method became http.request.method, http.status_code became http.response.status_code, as the HTTP conventions matured toward stability. A platform running telemetry from dozens of independently-deployed services, instrumented at dozens of different points in time with dozens of different SDK versions, cannot force a synchronized rename across all of them on one afternoon. That's what the schema_url field in the wire shape above is for: every Resource and every Scope carries the URL of the convention version it was produced against, and OpenTelemetry publishes machine-readable schema files describing exactly how to translate between versions.

# The shape of a schema file, simplified — a real one lives at a
# published schema_url and describes exactly this kind of transform.
file_format: 1.1.0
schema_url: https://opentelemetry.io/schemas/1.21.0
versions:
  1.21.0:
    all:
      changes:
        - rename_attributes:
            attribute_map:
              http.method: http.request.method
              http.status_code: http.response.status_code
  1.4.0:
    all:
      changes:
        - rename_attributes:
            attribute_map:
              peer.service: peer.service   # unchanged in this version

A schema-aware backend reads the schema_url a Resource or Scope actually shipped with, applies the transform chain forward (or backward) to whichever version the backend wants to query against, and a dashboard built on http.request.method keeps working against a service that's still emitting the older http.method — no rename PR required across forty repositories before the query stops silently missing half its data. This is genuinely the least-known piece of the whole data model, because it only ever matters at the exact moment a convention changes underneath a running fleet — which is precisely the moment a platform team, not an app team, is the one holding the pager.

🦆 Dot's-eye view

"I filtered a Grafana panel by http.request.method and got half the traffic I expected. Nothing was broken — half our services were still on an SDK version old enough to emit http.method instead, and the panel had no idea the two meant the same thing. I'd assumed a semantic convention was as fixed as a database column name. It isn't; it's versioned, and the version travels with the data as a URL I'd never once looked at until that afternoon."

Putting it together: what this buys a platform team the exam doesn't test

☺ Like you're 10: All four pieces — the sandwich, the boundary, the shared shipping format, and the translator for old paperwork — only add up to something useful when a team actually owns the middle of the pipe, not just the two ends.

Put the four ideas on this page next to each other and a shape appears that no single OTCA competency states outright: the Resource/Scope/record sandwich is what makes telemetry attributable — you can always answer "which process, which library, which one event" without guessing. The API/SDK split is what makes a shared pipeline governable — a platform team can evolve sampling, redaction and export policy weekly without ever blocking on an app team's release cycle. OTLP's public protobuf shape is what makes the pipeline portable — genuinely swappable backends, not just promised ones. And schema URLs are what make the whole system durable — able to survive its own vocabulary changing underneath a fleet that will never all upgrade on the same day. None of these are things you demonstrate by passing a multiple-choice exam. All of them are things a platform team demonstrates by still having a working, queryable, trusted telemetry pipeline three years and four semantic-convention releases later.

This is also where the data model stops being an abstract layer and starts touching the rest of the campaign: policy-as-code is what actually enforces that every namespace carries the right k8s.* Resource attributes before telemetry ever leaves it; the Prometheus model is the metrics-shaped sibling of everything above, with its own take on what belongs at the label level versus the sample level; and the Platform Engineering course's OpenTelemetry page is the right next stop if you want the Collector's own component types — receivers, processors, exporters, connectors — covered with the same depth this page gave the data model underneath them.

🎬 At Mission Control
🐘

Ellie: Someone's Resource has customer.tier: gold baked into it. Every span this process ever emits, for its entire lifetime, now claims to be from a gold customer.

🦊

Foxy: That seems bad. Why would anyone do that?

👺

Gizmo: Because setting it once at start-up is way less typing than adding it to every span. Efficient, really. 🤑

🐢

Timmy the Turtle: A Resource describes the process, Gizmo, not the request currently passing through it. Set that once and every future customer's traffic gets mislabeled as gold until the process restarts.

🐘

Ellie: Right — that belongs as a span attribute, set fresh on every request. The Resource stays boring on purpose: service name, version, where it's running. Nothing that changes per call.

🦆

Dot: Honestly I didn't even know there was a difference until this broke my dashboard filters. I just called resource.create() once and moved on.

🐢

Timmy the Turtle: That's exactly why it belongs on a platform-owned Collector processor instead of forty independent SDK configs — one place decides what's Resource-shaped, and nobody has to re-learn the rule from scratch.

🐢 Timmy's checkpoint

1. Name the three layers of the OpenTelemetry data model and, for each, what question it answers and how long it lives. 2. Why is a customer ID or a cart total the wrong thing to put in a Resource, even though it would technically "work"? 3. What does it mean, concretely, that the OpenTelemetry API is stable and no-op by default — and why does that matter to a platform team's ability to change SDK configuration without app-team involvement? 4. What is OTLP, and what specific technical property lets a service fan the same telemetry out to two different backends at once? 5. What problem does a schema_url solve, and why can't a platform team just rename an attribute everywhere on one afternoon instead? 6. In the nested wire shape, which field can differ between a Resource and one of its own ScopeSpans, and why would that ever happen in practice?

Check your answers
  1. Resource — what produced this telemetry, set once by the SDK at process start-up, lives as long as the process. InstrumentationScope — which specific library (and version) emitted this record, set per call site, lives as long as that library keeps calling the API. The record (a Span, metric data point, or LogRecord) — what actually happened this one time, created fresh on every single event.
  2. A Resource is created once and reused for every record a process ever emits afterward. Anything that changes per request — a customer ID, a cart total — would get frozen onto every future span for the rest of that process's lifetime once set on the Resource, silently mislabeling unrelated later traffic. It belongs as a record-level (e.g. span) attribute instead, set fresh on each call.
  3. The API's method signatures don't change across SDK versions or backend choices, and calling them produces nothing at all until an SDK is registered behind them. That decouples "instrument the code" from "configure where telemetry goes and how it's sampled" — a platform team can change sampler, processor, exporter and resource configuration centrally (env vars, an Instrumentation CRD) without any application team changing a line of instrumented code or cutting a new release.
  4. OTLP (the OpenTelemetry Protocol) is the public Protocol Buffers wire format — over gRPC or HTTP — that every OTLP-compliant backend parses identically. Because the byte layout is a shared, public schema rather than a vendor's proprietary format, a Collector (or SDK) can export the exact same payload to two configured exporters at once, so the same telemetry reaches two independent backends simultaneously.
  5. Semantic conventions get renamed as they stabilize (http.methodhttp.request.method is a real example). A schema_url records which convention version a given Resource or Scope was produced against, and published schema files describe how to transform between versions — so a schema-aware backend can translate old and new attribute names against each other. A synchronized rename across every independently-deployed, independently-versioned service in a fleet isn't something a platform team can force to happen on one afternoon.
  6. The schema_url field appears on both ResourceSpans and ScopeSpans, and they can differ: a process's Resource might be produced by an up-to-date SDK on the latest convention version, while one specific instrumentation library inside that same process — one that hasn't been upgraded — still emits its scope's records against an older schema version.