1package telemetry
2
3import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11 "go.opentelemetry.io/otel/attribute"
12 otellog "go.opentelemetry.io/otel/log"
13 sdklog "go.opentelemetry.io/otel/sdk/log"
14 "go.opentelemetry.io/otel/sdk/resource"
15
16 "github.com/bnema/gordon/internal/domain"
17)
18
19type recordingExporter struct {
20 mu sync.Mutex
21 records []sdklog.Record
22 shutdown int
23}
24
25func (e *recordingExporter) Export(_ context.Context, records []sdklog.Record) error {
26 e.mu.Lock()
27 defer e.mu.Unlock()
28 for _, r := range records {
29 e.records = append(e.records, r.Clone())
30 }
31 return nil
32}
33
34func (e *recordingExporter) Shutdown(context.Context) error {
35 e.mu.Lock()
36 defer e.mu.Unlock()
37 e.shutdown++
38 return nil
39}
40
41func (e *recordingExporter) ForceFlush(context.Context) error { return nil }
42
43func resourceAttr(r sdklog.Record, key string) string {
44 res := r.Resource()
45 value, _ := res.Set().Value(attribute.Key(key))
46 return value.AsString()
47}
48
49func recordAttr(r sdklog.Record, key string) string {
50 var found string
51 r.WalkAttributes(func(kv attribute.KeyValue) bool {
52 if string(kv.Key) == key {
53 found = kv.Value.AsString()
54 return false
55 }
56 return true
57 })
58 return found
59}
60
61func TestLogExporter_ExportsPerSourceIdentity(t *testing.T) {
62 rec := &recordingExporter{}
63 base := resource.NewSchemaless(attribute.String("service.name", "gordon"), attribute.String("host.name", "node-1"))
64 exp := NewLogExporter(rec, base)
65 ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
66
67 exp.Export(context.Background(), domain.LogRecord{
68 Time: ts,
69 Source: domain.LogSource{App: "blog", Service: "web"},
70 Type: domain.LogTypeContainer,
71 Stream: domain.LogStreamStderr,
72 Severity: domain.LogSeverityError,
73 Body: "boom",
74 })
75 exp.Export(context.Background(), domain.LogRecord{
76 Time: ts,
77 Source: domain.LogSource{App: "shop", Service: "web"},
78 Body: "hello",
79 })
80 require.NoError(t, exp.Shutdown(context.Background()))
81
82 require.Len(t, rec.records, 2)
83 byService := map[string]sdklog.Record{}
84 for _, r := range rec.records {
85 byService[resourceAttr(r, "service.name")] = r
86 }
87 blog, ok := byService["blog.web"]
88 require.True(t, ok, "services with the same name stay distinct per app")
89 _, ok = byService["shop.web"]
90 require.True(t, ok)
91
92 assert.Equal(t, "blog", resourceAttr(blog, "service.namespace"))
93 assert.Equal(t, "blog", resourceAttr(blog, attrGordonApp))
94 assert.Equal(t, "web", resourceAttr(blog, attrGordonService))
95 assert.Equal(t, "node-1", resourceAttr(blog, "host.name"), "base attributes are kept")
96 assert.Equal(t, "boom", blog.Body().AsString())
97 assert.Equal(t, ts, blog.Timestamp())
98 assert.Equal(t, otellog.SeverityError, blog.Severity())
99 assert.Equal(t, domain.LogTypeContainer, recordAttr(blog, attrLogType))
100 assert.Equal(t, domain.LogStreamStderr, recordAttr(blog, attrLogStream))
101 assert.Equal(t, 1, rec.shutdown, "shared exporter is shut down exactly once")
102}
103
104func TestLogExporter_DropsAfterShutdown(t *testing.T) {
105 rec := &recordingExporter{}
106 exp := NewLogExporter(rec, nil)
107 require.NoError(t, exp.Shutdown(context.Background()))
108
109 exp.Export(context.Background(), domain.LogRecord{Source: domain.LogSource{App: "blog", Service: "web"}, Body: "late"})
110
111 assert.Empty(t, rec.records)
112}