|
| 1 | +// SPDX-FileCopyrightText: 2026 Paulo Almeida <almeidapaulopt@gmail.com> |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +package core |
| 5 | + |
| 6 | +import ( |
| 7 | + "context" |
| 8 | + "testing" |
| 9 | + |
| 10 | + "go.opentelemetry.io/otel/sdk/resource" |
| 11 | + tracesdk "go.opentelemetry.io/otel/sdk/trace" |
| 12 | + "go.opentelemetry.io/otel/sdk/trace/tracetest" |
| 13 | + semconv "go.opentelemetry.io/otel/semconv/v1.41.0" |
| 14 | +) |
| 15 | + |
| 16 | +// resourceString fetches a single string attribute from a *resource.Resource. |
| 17 | +// Returns ok=false when the attribute is missing. |
| 18 | +func resourceString(res *resource.Resource, key string) (string, bool) { |
| 19 | + if res == nil { |
| 20 | + return "", false |
| 21 | + } |
| 22 | + for _, kv := range res.Attributes() { |
| 23 | + if string(kv.Key) == key { |
| 24 | + return kv.Value.AsString(), true |
| 25 | + } |
| 26 | + } |
| 27 | + return "", false |
| 28 | +} |
| 29 | + |
| 30 | +func TestBuildResource_HasServiceIdentity(t *testing.T) { |
| 31 | + t.Parallel() |
| 32 | + |
| 33 | + res, err := buildResource(context.Background()) |
| 34 | + if err != nil { |
| 35 | + t.Fatalf("buildResource() error = %v", err) |
| 36 | + } |
| 37 | + if res == nil { |
| 38 | + t.Fatal("buildResource() returned nil resource") |
| 39 | + } |
| 40 | + |
| 41 | + name, ok := resourceString(res, "service.name") |
| 42 | + if !ok { |
| 43 | + t.Fatal("service.name must be present on the resource") |
| 44 | + } |
| 45 | + if name == "" { |
| 46 | + t.Fatal("service.name must not be empty") |
| 47 | + } |
| 48 | + |
| 49 | + // service.version mirrors the resolved build version. |
| 50 | + ver, ok := resourceString(res, "service.version") |
| 51 | + if !ok { |
| 52 | + t.Fatal("service.version must be present on the resource") |
| 53 | + } |
| 54 | + if ver != GetVersion() { |
| 55 | + t.Errorf("service.version = %q, want %q", ver, GetVersion()) |
| 56 | + } |
| 57 | + |
| 58 | + // service.instance.id is a per-process UUID; must be non-empty. |
| 59 | + id, ok := resourceString(res, "service.instance.id") |
| 60 | + if !ok { |
| 61 | + t.Fatal("service.instance.id must be present on the resource") |
| 62 | + } |
| 63 | + if id == "" { |
| 64 | + t.Fatal("service.instance.id must not be empty") |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +func TestBuildResource_HasHostAttributes(t *testing.T) { |
| 69 | + t.Parallel() |
| 70 | + |
| 71 | + res, err := buildResource(context.Background()) |
| 72 | + if err != nil { |
| 73 | + t.Fatalf("buildResource() error = %v", err) |
| 74 | + } |
| 75 | + |
| 76 | + host, ok := resourceString(res, "host.name") |
| 77 | + if !ok { |
| 78 | + t.Fatal("host.name must be present on the resource") |
| 79 | + } |
| 80 | + if host == "" { |
| 81 | + t.Fatal("host.name must not be empty") |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +func TestProcessInstanceID_StableAcrossCalls(t *testing.T) { |
| 86 | + t.Parallel() |
| 87 | + |
| 88 | + // The instance id is generated once per process; buildResource must keep |
| 89 | + // returning the same value so spans group correctly in the backend. |
| 90 | + r1, err := buildResource(context.Background()) |
| 91 | + if err != nil { |
| 92 | + t.Fatalf("buildResource() r1 error = %v", err) |
| 93 | + } |
| 94 | + r2, err := buildResource(context.Background()) |
| 95 | + if err != nil { |
| 96 | + t.Fatalf("buildResource() r2 error = %v", err) |
| 97 | + } |
| 98 | + |
| 99 | + id1, _ := resourceString(r1, "service.instance.id") |
| 100 | + id2, _ := resourceString(r2, "service.instance.id") |
| 101 | + if id1 != id2 { |
| 102 | + t.Errorf("service.instance.id not stable: %q vs %q", id1, id2) |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +// TestBuildResource_OTELServiceNameOverridesDefault verifies that the env var |
| 107 | +// takes precedence over the code default. This is the precedence contract: |
| 108 | +// WithFromEnv() is ordered last so operators can rename the service without a |
| 109 | +// rebuild. |
| 110 | +func TestBuildResource_OTELServiceNameOverridesDefault(t *testing.T) { |
| 111 | + t.Setenv("OTEL_SERVICE_NAME", "custom-tsdproxy") |
| 112 | + |
| 113 | + res, err := buildResource(context.Background()) |
| 114 | + if err != nil { |
| 115 | + t.Fatalf("buildResource() error = %v", err) |
| 116 | + } |
| 117 | + |
| 118 | + name, ok := resourceString(res, "service.name") |
| 119 | + if !ok { |
| 120 | + t.Fatal("service.name missing") |
| 121 | + } |
| 122 | + if name != "custom-tsdproxy" { |
| 123 | + t.Errorf("service.name = %q, want %q (env must override code default)", name, "custom-tsdproxy") |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +// TestBuildResource_OTelResourceAttributesMerged verifies that arbitrary |
| 128 | +// attributes set via OTEL_RESOURCE_ATTRIBUTES are surfaced on the resource |
| 129 | +// (e.g. deployment.environment). |
| 130 | +func TestBuildResource_OTelResourceAttributesMerged(t *testing.T) { |
| 131 | + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment.name=prod") |
| 132 | + |
| 133 | + res, err := buildResource(context.Background()) |
| 134 | + if err != nil { |
| 135 | + t.Fatalf("buildResource() error = %v", err) |
| 136 | + } |
| 137 | + |
| 138 | + env, ok := resourceString(res, "deployment.environment.name") |
| 139 | + if !ok { |
| 140 | + t.Fatal("deployment.environment.name from OTEL_RESOURCE_ATTRIBUTES must be merged") |
| 141 | + } |
| 142 | + if env != "prod" { |
| 143 | + t.Errorf("deployment.environment.name = %q, want %q", env, "prod") |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +// TestInitTracer_ResourceAttachedToSpans is an end-to-end check that a span |
| 148 | +// emitted through a resource-backed provider carries the resource attributes |
| 149 | +// (the part a backend like OpenObserve actually indexes on). |
| 150 | +func TestInitTracer_ResourceAttachedToSpans(t *testing.T) { |
| 151 | + t.Parallel() |
| 152 | + |
| 153 | + exporter := tracetest.NewInMemoryExporter() |
| 154 | + tp := tracesdk.NewTracerProvider( |
| 155 | + tracesdk.WithSyncer(exporter), |
| 156 | + tracesdk.WithResource(resource.NewSchemaless( |
| 157 | + semconv.ServiceName("tsdproxy"), |
| 158 | + semconv.ServiceVersion(GetVersion()), |
| 159 | + )), |
| 160 | + ) |
| 161 | + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) |
| 162 | + |
| 163 | + _, span := tp.Tracer("test").Start(context.Background(), "probe") |
| 164 | + span.End() |
| 165 | + |
| 166 | + spans := exporter.GetSpans() |
| 167 | + if len(spans) != 1 { |
| 168 | + t.Fatalf("got %d spans, want 1", len(spans)) |
| 169 | + } |
| 170 | + name, ok := resourceString(spans[0].Resource, "service.name") |
| 171 | + if !ok { |
| 172 | + t.Fatal("service.name missing on span resource") |
| 173 | + } |
| 174 | + if name != "tsdproxy" { |
| 175 | + t.Errorf("service.name on span = %q, want %q", name, "tsdproxy") |
| 176 | + } |
| 177 | +} |
0 commit comments