OpenTelemetry resource attributes are the quiet heroes, providing indispensable context that makes tracing and metrics actually useful, not just a firehose of data.

Let’s see this in action. Imagine you’ve got a simple Go application that exposes an HTTP endpoint. You’re using OpenTelemetry to trace requests. Without resource attributes, your traces might look like this:

HTTP Request Received (span)
  -> Database Query (span)
    -> External API Call (span)

This tells you what happened, but not where. Was this the user-service on production-us-east-1? Or the order-service on staging-eu-west-2? Resource attributes answer that.

Here’s how you’d add them to that Go service:

import (
	"context"
	"log"
	"net/http"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/otel/sdk/resource"
	"go.opentelemetry.io/otel/sdk/trace"
)

func initProvider() (*trace.TracerProvider, error) {
	exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
	if err != nil {
		return nil, err
	}

	// Define your resource attributes
	res, err := resource.New(context.Background(),
		resource.WithAttributes(
			attribute.String("service.name", "my-go-app"),
			attribute.String("environment", "development"),
			attribute.String("cloud.provider", "aws"),
			attribute.String("cloud.region", "us-east-1"),
			attribute.String("host.name", "localhost"),
		),
	)
	if err != nil {
		return nil, err
	}

	tp := trace.NewTracerProvider(
		trace.WithBatcher(exporter),
		trace.WithResource(res), // <-- This is where you attach the resource
	)
	return tp, nil
}

func main() {
	tp, err := initProvider()
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		if err := tp.Shutdown(context.Background()); err != nil {
			log.Printf("Error shutting down tracer provider: %v", err)
		}
	}()

	otel.SetTracerProvider(tp)

	tracer := otel.Tracer("my-app-tracer")
	ctx := context.Background()

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		ctx, span := tracer.Start(ctx, "HTTP Request Handler")
		defer span.End()

		// Simulate some work
		_, dbSpan := tracer.Start(ctx, "Database Query")
		dbSpan.SetAttributes(attribute.String("db.system", "postgresql"))
		dbSpan.SetAttributes(attribute.String("db.statement", "SELECT * FROM users"))
		dbSpan.End()

		_, apiSpan := tracer.Start(ctx, "External API Call")
		apiSpan.SetAttributes(attribute.String("http.method", "GET"))
		apiSpan.SetAttributes(attribute.String("http.url", "https://api.example.com/data"))
		apiSpan.End()

		w.Write([]byte("Hello, World!"))
	})

	log.Println("Server starting on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}

When you run this and hit http://localhost:8080, your stdout exporter will show structured data. Crucially, each span will now have a Resource section, looking something like this:

{
  "resource": {
    "attributes": [
      {
        "key": "service.name",
        "value": {
          "stringValue": "my-go-app"
        }
      },
      {
        "key": "environment",
        "value": {
          "stringValue": "development"
        }
      },
      // ... other attributes
    ]
  },
  "instrumentationLibrary": {
    "name": "my-app-tracer",
    "version": "v0.0.0"
  },
  "spans": [
    {
      "traceId": "...",
      "spanId": "...",
      "parentSpanId": null,
      "name": "HTTP Request Handler",
      "kind": 0,
      "startTimeUnixNano": "...",
      "endTimeUnixNano": "...",
      "attributes": [],
      "events": [],
      "status": {
        "code": 0
      }
    },
    {
      "traceId": "...",
      "spanId": "...",
      "parentSpanId": "...", // This will be the traceId of the "HTTP Request Handler" span
      "name": "Database Query",
      "kind": 0,
      "startTimeUnixNano": "...",
      "endTimeUnixNano": "...",
      "attributes": [
        {
          "key": "db.system",
          "value": {
            "stringValue": "postgresql"
          }
        },
        // ... other db attributes
      ],
      // ...
    },
    // ... other spans
  ]
}

See how the resource block is present in every span? This means when this data lands in your observability backend (like Jaeger, Datadog, or Splunk), you can immediately filter and group by service.name, environment, cloud.provider, etc. It’s the difference between seeing a million individual requests and understanding the performance of your user-service in production-us-east-1.

The core problem resource attributes solve is attribution. When an error occurs or a request is slow, you need to know which instance of which service is responsible. Without resource attributes, this is a manual, often impossible, detective job. With them, it’s a simple query.

The resource object in OpenTelemetry is a collection of key-value pairs that describe the entity producing telemetry. These entities can be applications, hosts, containers, cloud instances, or even entire environments. The OpenTelemetry semantic conventions define standard attribute names (like service.name, host.name, container.id) to ensure interoperability across different vendors and tools.

You can set resource attributes in several ways:

  1. Programmatically: As shown in the Go example, you can create a resource.Resource object and pass it to the TracerProvider (or MeterProvider). This is common for custom configurations.
  2. Environment Variables: Many SDKs automatically pick up attributes from environment variables. For example, OTEL_SERVICE_NAME=my-service will set the service.name attribute. OTEL_RESOURCE_ATTRIBUTES=environment=production,cloud.provider=gcp sets multiple attributes. This is great for containerized environments.
  3. Auto-detection: The OpenTelemetry SDKs often include auto-detection capabilities. For instance, when running in a Kubernetes pod, it might automatically detect k8s.pod.name, k8s.namespace.name, and k8s.node.name. Similarly, on AWS EC2, it might detect cloud.instance.id and cloud.region.

The resource.New function in Go is a common way to build these, often combining programmatic definitions with automatically detected attributes. The SDKs typically merge these sources, with programmatically defined attributes often overriding environment variables, and environment variables overriding auto-detection.

A key aspect often overlooked is the granularity of resource attributes. While service.name is fundamental, you can go much deeper. Consider adding deployment.environment (e.g., staging, production, qa), cloud.account.id, k8s.cluster.name, process.pid, or even custom identifiers like team.name or application.version. The more context you provide, the richer your analysis becomes.

The resource.WithHost() function in some SDKs (though not directly shown in the basic Go example above, it’s a common helper) will attempt to automatically populate host.name and host.id. Similarly, resource.WithContainer() or specific cloud provider resource detectors can enrich attributes automatically. If you’re running in Kubernetes, you’d typically use resource.WithKubernetes() or rely on the collector’s Kubernetes attributes processor to inject k8s.pod.uid, k8s.pod.name, k8s.namespace.name, etc., into the resource.

The most surprising truth is that resource attributes are not just metadata for your telemetry backend; they are deeply integrated into the OpenTelemetry API itself. When you start a span with tracer.Start(ctx, "my-span"), the ctx object implicitly carries the resource information. This means that even if a span doesn’t explicitly set an attribute, it inherits the resource attributes from its parent context or the global provider. This inheritance is what ensures every single piece of telemetry, no matter how small, is tied back to its origin.

Once you have resource attributes set correctly, the next logical step is to ensure your traces and metrics are linked.

Want structured learning?

Take the full Opentelemetry course →