| Status | Active |
|---|---|
| Owner | HyperFleet Platform Team |
| Last Updated | 2026-03-27 |
- Overview
- OpenTelemetry Adoption
- Configuration
- Trace Context Propagation
- Required Spans
- Standard Span Attributes
- Sampling Strategy
- Exporter Configuration
- Integration with Logging
- Error Handling in Spans
- Span Lifecycle Best Practices
- References
This document defines the standard approach for distributed tracing across all HyperFleet components (API, Sentinel, Adapters).
- Visibility: End-to-end request tracing across API, Sentinel, and Adapters
- Correlation: Link traces with logs and metrics for unified observability
- Consistency: All components instrument tracing the same way
- Debuggability: Enable efficient troubleshooting of distributed operations
- Observability infrastructure setup (backend, storage, visualization)
- Retroactive instrumentation of existing code (separate tickets)
- Creating a shared tracing library
HyperFleet adopts OpenTelemetry as the standard for distributed tracing.
- Vendor-neutral: Works with any observability backend (Jaeger, Tempo, Cloud Trace, etc.)
- Industry standard: CNCF project with broad adoption
- Unified API: Single API for traces, metrics, and logs
- Auto-instrumentation: Libraries available for common frameworks
- W3C Trace Context: Native support for standard trace propagation
All HyperFleet components MUST use the OpenTelemetry Go SDK:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
)All components MUST support tracing configuration via environment variables. Command-line flags are optional.
| Environment Variable | Default | Description |
|---|---|---|
OTEL_SERVICE_NAME |
Component name | Service name for traces |
OTEL_EXPORTER_OTLP_ENDPOINT |
localhost:4317 |
OTLP gRPC endpoint |
OTEL_EXPORTER_OTLP_PROTOCOL |
grpc |
Protocol: grpc or http/protobuf |
OTEL_TRACES_SAMPLER |
parentbased_traceidratio |
Sampler type |
OTEL_TRACES_SAMPLER_ARG |
1.0 |
Sampler argument (ratio for ratio-based samplers) |
OTEL_PROPAGATORS |
tracecontext,baggage |
Context propagators |
OTEL_RESOURCE_ATTRIBUTES |
- | Additional resource attributes |
HYPERFLEET_TRACING_ENABLED |
true |
Enable/disable tracing |
Each component MUST set OTEL_SERVICE_NAME to its component name:
| Component | Service Name |
|---|---|
| API | hyperfleet-api |
| Sentinel | hyperfleet-sentinel |
| Validation Adapter | hyperfleet-adapter-validation |
| Provisioning Adapter | hyperfleet-adapter-provisioning |
Components SHOULD include the following resource attributes via OTEL_RESOURCE_ATTRIBUTES:
| Attribute | Source | Description |
|---|---|---|
service.version |
Build process | Service version from git SHA (e.g., a1b2c3d) |
k8s.namespace.name |
Helm/Downward API | Kubernetes namespace for environment isolation |
Note: The
k8s.namespace.nameattribute serves as the environment differentiator. Different namespaces (e.g.,hyperfleet-prod,hyperfleet-stage,hyperfleet-dev-rafael) naturally isolate traces in multi-tenant deployments. Thedeployment.environmentattribute is optional and can be omitted when namespace-based isolation is sufficient.
Use the git SHA as the version during build (as already done in hyperfleet-api):
build_version := $(shell git rev-parse --short HEAD)$(shell git diff --quiet || echo '-dirty')
ldflags := -X main.Version=$(build_version)Then set the resource attribute at runtime:
env:
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.version=$(VERSION),k8s.namespace.name=$(NAMESPACE)"OTEL_RESOURCE_ATTRIBUTES="service.version=a1b2c3d,k8s.namespace.name=hyperfleet-stage"HyperFleet uses W3C Trace Context for trace propagation across service boundaries.
Extract and inject trace context from/to HTTP headers:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
// Server: Extract context from incoming request
ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))
// Client: Inject context into outgoing request
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))Trace context MUST be propagated via CloudEvents extension attributes:
| Extension | Description |
|---|---|
traceparent |
W3C Trace Context traceparent header |
tracestate |
W3C Trace Context tracestate header (optional) |
// Publisher: Add trace context to CloudEvent
event := cloudevents.NewEvent()
event.SetExtension("traceparent", traceparentFromContext(ctx))
// Subscriber: Extract trace context from CloudEvent
traceparent, _ := event.Extensions()["traceparent"].(string)
ctx := contextFromTraceparent(ctx, traceparent)HyperFleet has two main trace flows:
- Reconciliation: Sentinel → API → Sentinel → Pub/Sub → Adapter (asynchronous)
- Client requests: Client → API (synchronous HTTP)
flowchart TB
subgraph Client Flow
C[Client] -->|traceparent<br/>HTTP| API1[API]
API1 --> L1[Logs<br/>trace_id]
end
subgraph Reconciliation Flow
S[Sentinel] -->|traceparent<br/>HTTP| API2[API]
API2 --> L2[Logs<br/>trace_id]
API2 -->|response| S
S --> L3[Logs<br/>trace_id]
S -->|traceparent<br/>CloudEvent| PS[Pub/Sub]
PS --> AD[Adapter]
AD --> L4[Logs<br/>trace_id]
AD -->|GET status<br/>traceparent HTTP| API3[API]
API3 --> L5[Logs<br/>trace_id]
API3 -->|response| AD
AD -->|PATCH status<br/>traceparent HTTP| API4[API]
API4 --> L6[Logs<br/>trace_id]
end
Note: Sentinel initiates traces during polling cycles. The trace context propagates through CloudEvents to Adapters, which include it when updating status back to the API.
Components MUST create spans for the following operations.
Follow OpenTelemetry Semantic Conventions for span names:
| Span Type | Naming Pattern | Example |
|---|---|---|
| HTTP Server | {method} {route} |
GET /clusters/{id} |
| HTTP Client | {method} |
GET |
| Database | {operation} {table} |
SELECT clusters |
| Messaging | {destination} {operation} |
hyperfleet-clusters publish |
| Custom | {component}.{operation} |
sentinel.evaluate |
Note: Use attributes (not span names) for high-cardinality values like IDs, hostnames, or dynamic paths.
| Operation | Span Name | Required |
|---|---|---|
| HTTP request handling | {method} {route} |
When exposing HTTP |
| External HTTP calls | {method} |
When making HTTP calls |
| Database operations | {operation} {table} |
When using database |
| Operation | Span Name Pattern | Example |
|---|---|---|
| Request handling | {method} {route} |
GET /clusters/{id} |
| Database query | {operation} {table} |
SELECT clusters |
| Authentication | api.{operation} |
api.validate_token |
| Operation | Span Name Pattern | Example |
|---|---|---|
| Poll cycle | sentinel.{operation} |
sentinel.poll |
| Decision evaluation | sentinel.{operation} |
sentinel.evaluate |
| Event publish | {destination} {operation} |
hyperfleet-clusters publish |
| API call | {method} |
GET |
Note: HTTP client span names use only the method per OpenTelemetry semantic conventions. To differentiate calls to different APIs, use the
server.addressandurl.pathattributes (see HTTP Spans below).
| Operation | Span Name Pattern | Example |
|---|---|---|
| Event receive | {destination} {operation} |
hyperfleet-clusters receive |
| Event process | adapter.{operation} |
adapter.process |
| Cloud provider call | {method} |
POST |
| Status update | {method} |
PATCH |
Note: For cloud provider calls, use the
cloud.provider,cloud.service, andserver.addressattributes to identify which service is being called (see Cloud Provider Spans below).
Follow OpenTelemetry Semantic Conventions for common attributes.
| Attribute | Type | Description |
|---|---|---|
http.request.method |
string | HTTP method |
url.path |
string | Request path (sanitized) |
http.response.status_code |
int | Response status code |
server.address |
string | Server hostname |
http.route |
string | Route template (e.g., /clusters/{id}) |
| Attribute | Type | Description |
|---|---|---|
db.system |
string | Database type (postgresql, redis) |
db.operation.name |
string | Operation (SELECT, INSERT, etc.) |
db.collection.name |
string | Table/collection name |
| Attribute | Type | Description |
|---|---|---|
messaging.system |
string | Messaging system (gcp_pubsub) |
messaging.operation.type |
string | Operation (publish, receive, process) |
messaging.destination.name |
string | Topic or subscription name |
messaging.message.id |
string | Message ID |
| Attribute | Type | Description |
|---|---|---|
cloud.provider |
string | Cloud provider (gcp, aws, azure) |
cloud.service |
string | Cloud service name (gke, storage, pubsub) |
server.address |
string | API endpoint hostname |
cloud.resource_id |
string | Resource identifier (e.g., cluster name, bucket name) |
Example for a GKE API call:
span.SetAttributes(
attribute.String("cloud.provider", "gcp"),
attribute.String("cloud.service", "gke"),
attribute.String("server.address", "container.googleapis.com"),
attribute.String("cloud.resource_id", "projects/my-project/locations/us-central1/clusters/my-cluster"),
)| Attribute | Type | Description |
|---|---|---|
hyperfleet.cluster_id |
string | Cluster identifier |
hyperfleet.resource_type |
string | Resource type (clusters, nodepools) |
hyperfleet.resource_id |
string | Resource identifier |
hyperfleet.adapter |
string | Adapter type name |
hyperfleet.decision_reason |
string | Sentinel decision reason |
DO:
- Use semantic conventions where applicable
- Include resource identifiers for debugging
- Sanitize paths to avoid high cardinality
DON'T:
- Include sensitive data (tokens, passwords, PII)
- Use high-cardinality values as span names
- Include large payloads in attributes
| Approach | Decision Point | Pros | Cons |
|---|---|---|---|
| Head-based | At trace start | Simple, low overhead, no data buffering | Cannot sample based on outcome (errors, latency) |
| Tail-based | After trace completes | Can sample interesting traces (errors, slow) | Requires buffering, higher resource usage |
HyperFleet uses head-based sampling for simplicity and lower operational overhead. Tail-based sampling requires additional infrastructure (OpenTelemetry Collector with tail sampling processor) and is recommended only when error/latency-based sampling is critical.
HyperFleet uses parentbased_traceidratio as the default sampler.
Why this sampler:
- Recommended by OpenTelemetry - This is the default production sampler suggested by OTel SDKs and Collector documentation
- Trace completeness - Parent-based sampling ensures all spans in a trace are either sampled or not, avoiding broken/incomplete traces
- Distributed consistency - When Sentinel starts a trace and it propagates to Adapters via Pub/Sub, all downstream services respect the original sampling decision
- Predictable costs - The ratio-based approach provides consistent sampling rates for capacity planning
How it works:
- If parent span exists: Follow parent's sampling decision (preserves trace completeness)
- If no parent (root span): Sample based on trace ID ratio
| Environment | Sampling Rate | Rationale |
|---|---|---|
| Development | 1.0 (100%) |
Full visibility for debugging |
| Staging | 0.1 (10%) |
Balance between visibility and cost |
| Production | 0.01 (1%) |
Cost-effective for high-traffic systems |
# Development
OTEL_TRACES_SAMPLER=always_on
# Production
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.01With head-based sampling, you can force 100% sampling for known operations at trace start:
- Specific API routes (e.g.,
/admin/*,/debug/*) - Specific resource types (e.g., critical clusters)
- Requests with debug headers
This is achieved using a custom sampler that checks request attributes before the operation executes.
Limitation: Head-based sampling cannot sample based on outcomes (errors, latency) because the decision is made before the operation completes. To capture all errors or slow requests, tail-based sampling with an OpenTelemetry Collector would be required. This is a future enhancement if needed.
All components MUST support OTLP as the primary export format:
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")),
otlptracegrpc.WithInsecure(), // For local development
)In Kubernetes, configure the OTLP endpoint to point to an OpenTelemetry Collector:
env:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "otel-collector.observability.svc:4317"
- name: OTEL_SERVICE_NAME
value: "hyperfleet-sentinel"
- name: OTEL_TRACES_SAMPLER
value: "parentbased_traceidratio"
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.01"For local development, use a local collector or stdout exporter:
# Use stdout for debugging
OTEL_TRACES_EXPORTER=console
# Or use a local Jaeger instance
OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317Traces MUST be correlated with logs via trace_id and span_id fields.
import (
"log/slog"
"go.opentelemetry.io/otel/trace"
)
func logWithTrace(ctx context.Context, logger *slog.Logger, msg string, args ...any) {
spanCtx := trace.SpanContextFromContext(ctx)
if spanCtx.HasTraceID() {
args = append(args,
"trace_id", spanCtx.TraceID().String(),
"span_id", spanCtx.SpanID().String(),
)
}
logger.InfoContext(ctx, msg, args...)
}{
"timestamp": "2025-01-15T10:30:00.123Z",
"level": "info",
"message": "Processing cluster event",
"component": "adapter-validation",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"cluster_id": "cls-123"
}For detailed logging requirements, see the Logging Specification.
When an error occurs, record it on the span:
import (
"go.opentelemetry.io/otel/codes"
)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())Include relevant context when recording errors:
span.RecordError(err,
trace.WithAttributes(
attribute.String("error.type", "validation_error"),
attribute.String("cluster_id", clusterID),
),
)ctx, span := tracer.Start(ctx, "operation.name",
trace.WithSpanKind(trace.SpanKindServer),
)
defer span.End()
// Do work...
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "operation failed")
return err
}
span.SetStatus(codes.Ok, "")Always pass context through the call chain:
// Good: Context is propagated
func ProcessEvent(ctx context.Context, event Event) error {
ctx, span := tracer.Start(ctx, "adapter.process")
defer span.End()
return updateStatus(ctx, event.ClusterID)
}
// Bad: Context is not propagated, trace is broken
func ProcessEvent(ctx context.Context, event Event) error {
_, span := tracer.Start(ctx, "adapter.process")
defer span.End()
return updateStatus(context.Background(), event.ClusterID) // Trace broken!
}