Skip to content

Commit 3f57b76

Browse files
authored
feat(llmobs): add WithAnnotatedCostTagKeys (DataDog#4728)
### What does this PR do? Adds `llmobs.WithAnnotatedCostTagKeys`, allowing users to mark existing LLMObs span tag keys for propagation to LLM cost and token metrics. Behavior mirrors dd-trace-py and dd-trace-js: - Cost tag keys are accepted through manual annotation APIs. - Keys must reference tags already present on the LLMObs span, either from the same annotation or a previous annotation. - Valid keys are deduplicated across annotations. - Valid cost tags are serialized on span events at `meta.metadata._dd.cost_tags`. - Invalid/missing tag keys are skipped with a warning and do not fail annotation. - Empty cost tag lists do not create `metadata._dd`. This also adds LLMObs telemetry counters for cost-tag usage: - `cost_tags.annotated` - `cost_tags.submitted` ### Motivation Bring dd-trace-go behavior in line with: - DataDog/dd-trace-py#17628 - DataDog/dd-trace-js#8175 This lets users break down generated LLM cost and token metrics by custom span dimensions such as team, project, service line, or a run/debug identifier. ### Testing Added focused coverage for: - Public `WithAnnotatedCostTagKeys` API behavior. - Same-call and prior-annotation tag references. - Deduplication across annotations. - Missing tag references being skipped. - Empty cost tag lists not creating `metadata._dd`. - Preserving existing `metadata._dd` fields. - Cost tags on non-LLM spans serializing cleanly. - Cost tags annotated after span finish being dropped. - Option ordering with `WithAnnotatedSessionID`. ### Reviewer's Checklist <!-- * Authors can use this list as a reference to ensure that there are no problems during the review but the signing off is to be done by the reviewer(s). --> - [ ] Changed code has unit tests for its functionality at or near 100% coverage. - [ ] [System-Tests](https://github.com/DataDog/system-tests/) covering this feature have been added and enabled with the va.b.c-dev version tag. - [ ] There is a benchmark for any new code, or changes to existing code. - [ ] If this interacts with the agent in a new way, a system test has been added. - [ ] New code is free of linting errors. You can check this by running `make lint` locally. - [ ] New code doesn't break existing tests. You can check this by running `make test` locally. - [ ] Add an appropriate team label so this PR gets put in the right place for the release notes. - [ ] All generated files are up to date. You can check this by running `make generate` locally. - [ ] Non-trivial go.mod changes, e.g. adding new modules, are reviewed by @DataDog/dd-trace-go-guild. Make sure all nested modules are up to date by running `make fix-modules` locally. Unsure? Have a question? Request a review! Co-authored-by: xinyuan.guo <xinyuan.guo@datadoghq.com>
1 parent 080bac9 commit 3f57b76

7 files changed

Lines changed: 337 additions & 4 deletions

File tree

internal/llmobs/llmobs.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ type llmobsContext struct {
9696
metadata map[string]any
9797
metrics map[string]float64
9898
tags map[string]string
99+
costTags []string
99100

100101
// agent specific
101102
agentManifest string
@@ -491,15 +492,14 @@ func (l *LLMObs) llmobsSpanEvent(span *Span) *transport.LLMObsSpanEvent {
491492
}
492493

493494
metadata := span.llmCtx.metadata
494-
if metadata == nil {
495+
if len(metadata) > 0 {
496+
metadata = maps.Clone(metadata)
497+
} else {
495498
metadata = make(map[string]any)
496499
}
497500
if spanKind == SpanKindAgent && span.llmCtx.agentManifest != "" {
498501
metadata["agent_manifest"] = span.llmCtx.agentManifest
499502
}
500-
if len(metadata) > 0 {
501-
meta["metadata"] = metadata
502-
}
503503

504504
input := make(map[string]any)
505505
output := make(map[string]any)
@@ -618,6 +618,12 @@ func (l *LLMObs) llmobsSpanEvent(span *Span) *transport.LLMObsSpanEvent {
618618
}
619619

620620
maps.Copy(tags, span.llmCtx.tags)
621+
622+
setMetadataCostTags(metadata, validateCostTags(span, tags))
623+
if len(metadata) > 0 {
624+
meta["metadata"] = metadata
625+
}
626+
621627
tagsSlice := make([]string, 0, len(tags))
622628
for k, v := range tags {
623629
tagsSlice = append(tagsSlice, fmt.Sprintf("%s:%s", k, v))
@@ -675,6 +681,53 @@ func (l *LLMObs) llmobsSpanEvent(span *Span) *transport.LLMObsSpanEvent {
675681
return ev
676682
}
677683

684+
// validateCostTags filters the span's annotated cost tags against the final
685+
// event tag set, drops entries that don't reference an emitted tag key, and
686+
// emits the cost-tags-submitted telemetry. It must run after the final tags
687+
// map is fully assembled, so cost tags referencing SDK-injected keys (e.g.
688+
// session_id from WithSessionID or propagation, integration, ml_app) are
689+
// accepted.
690+
func validateCostTags(span *Span, finalTags map[string]string) []string {
691+
costTags := span.llmCtx.costTags
692+
if len(costTags) == 0 {
693+
return nil
694+
}
695+
696+
validated := make([]string, 0, len(costTags))
697+
missing := 0
698+
for _, costTag := range costTags {
699+
if _, ok := finalTags[costTag]; !ok {
700+
log.Warn("llmobs: cost_tags entry %q must reference a key present in span tags. Skipping entry.", costTag)
701+
missing++
702+
continue
703+
}
704+
validated = append(validated, costTag)
705+
}
706+
707+
if missing > 0 {
708+
trackCostTagsSubmitted(span, missing, "annotate", "error", "missing_span_tag")
709+
}
710+
if len(validated) > 0 {
711+
trackCostTagsSubmitted(span, len(validated), "annotate", "success", "none")
712+
}
713+
return validated
714+
}
715+
716+
func setMetadataCostTags(metadata map[string]any, costTags []string) {
717+
if len(costTags) == 0 {
718+
return
719+
}
720+
721+
ddMetadata, ok := metadata["_dd"].(map[string]any)
722+
if ok {
723+
ddMetadata = maps.Clone(ddMetadata)
724+
} else {
725+
ddMetadata = make(map[string]any)
726+
}
727+
ddMetadata["cost_tags"] = append([]string(nil), costTags...)
728+
metadata["_dd"] = ddMetadata
729+
}
730+
678731
func dropSpanEventIO(ev *transport.LLMObsSpanEvent) bool {
679732
if ev == nil {
680733
return false

internal/llmobs/llmobs_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,158 @@ func TestSpanAnnotate(t *testing.T) {
866866
}
867867
}
868868

869+
func TestSpanAnnotateCostTags(t *testing.T) {
870+
t.Run("sets-cost-tags-on-span-event-metadata", func(t *testing.T) {
871+
tt, ll := testTracer(t)
872+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{})
873+
span.Annotate(llmobs.SpanAnnotations{
874+
Tags: map[string]string{"team": "ml", "feature": "chatbot", "debug_id": "abc"},
875+
CostTags: []string{"team", "feature"},
876+
})
877+
span.Finish(llmobs.FinishSpanConfig{})
878+
879+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
880+
assert.Equal(t, []any{"team", "feature"}, costTagsFromSpan(t, llmSpans[0]))
881+
})
882+
883+
t.Run("dedupes-cost-tags-across-annotations", func(t *testing.T) {
884+
tt, ll := testTracer(t)
885+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{})
886+
span.Annotate(llmobs.SpanAnnotations{
887+
Tags: map[string]string{"team": "ml", "feature": "chatbot"},
888+
CostTags: []string{"team", "feature", "team"},
889+
})
890+
span.Annotate(llmobs.SpanAnnotations{
891+
Tags: map[string]string{"project": "alpha"},
892+
CostTags: []string{"feature", "project"},
893+
})
894+
span.Finish(llmobs.FinishSpanConfig{})
895+
896+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
897+
assert.Equal(t, []any{"team", "feature", "project"}, costTagsFromSpan(t, llmSpans[0]))
898+
})
899+
900+
t.Run("skips-cost-tags-that-do-not-reference-span-tags", func(t *testing.T) {
901+
tt, ll := testTracer(t)
902+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{})
903+
span.Annotate(llmobs.SpanAnnotations{
904+
Tags: map[string]string{"team": "ml"},
905+
CostTags: []string{"team", "missing"},
906+
})
907+
span.Finish(llmobs.FinishSpanConfig{})
908+
909+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
910+
assert.Equal(t, []any{"team"}, costTagsFromSpan(t, llmSpans[0]))
911+
})
912+
913+
t.Run("references-tags-from-a-prior-annotation", func(t *testing.T) {
914+
tt, ll := testTracer(t)
915+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{})
916+
span.Annotate(llmobs.SpanAnnotations{Tags: map[string]string{"team": "ml"}})
917+
span.Annotate(llmobs.SpanAnnotations{CostTags: []string{"team"}})
918+
span.Finish(llmobs.FinishSpanConfig{})
919+
920+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
921+
assert.Equal(t, []any{"team"}, costTagsFromSpan(t, llmSpans[0]))
922+
})
923+
924+
t.Run("empty-list-does-not-create-metadata-dd", func(t *testing.T) {
925+
tt, ll := testTracer(t)
926+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{})
927+
span.Annotate(llmobs.SpanAnnotations{
928+
Tags: map[string]string{"team": "ml"},
929+
CostTags: []string{},
930+
})
931+
span.Finish(llmobs.FinishSpanConfig{})
932+
933+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
934+
assert.NotContains(t, llmSpans[0].Meta, "metadata")
935+
})
936+
937+
t.Run("preserves-existing-metadata-dd-fields", func(t *testing.T) {
938+
tt, ll := testTracer(t)
939+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{})
940+
span.Annotate(llmobs.SpanAnnotations{
941+
Metadata: map[string]any{"_dd": map[string]any{"existing": "value"}},
942+
Tags: map[string]string{"team": "ml"},
943+
CostTags: []string{"team"},
944+
})
945+
span.Finish(llmobs.FinishSpanConfig{})
946+
947+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
948+
metadata := llmSpans[0].Meta["metadata"].(map[string]any)
949+
ddMetadata := metadata["_dd"].(map[string]any)
950+
assert.Equal(t, "value", ddMetadata["existing"])
951+
assert.Equal(t, []any{"team"}, ddMetadata["cost_tags"])
952+
})
953+
954+
t.Run("serializes-cost-tags-on-non-llm-span", func(t *testing.T) {
955+
tt, ll := testTracer(t)
956+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindWorkflow, "", llmobs.StartSpanConfig{})
957+
span.Annotate(llmobs.SpanAnnotations{
958+
Tags: map[string]string{"team": "ml"},
959+
CostTags: []string{"team"},
960+
})
961+
span.Finish(llmobs.FinishSpanConfig{})
962+
963+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
964+
assert.Equal(t, "workflow", llmSpans[0].Meta["span.kind"])
965+
assert.Equal(t, []any{"team"}, costTagsFromSpan(t, llmSpans[0]))
966+
})
967+
968+
t.Run("references-session-id-from-start-config", func(t *testing.T) {
969+
tt, ll := testTracer(t)
970+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{
971+
SessionID: "session-123",
972+
})
973+
span.Annotate(llmobs.SpanAnnotations{
974+
CostTags: []string{"session_id"},
975+
})
976+
span.Finish(llmobs.FinishSpanConfig{})
977+
978+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
979+
assert.Equal(t, "session-123", llmSpans[0].SessionID)
980+
assert.Equal(t, []any{"session_id"}, costTagsFromSpan(t, llmSpans[0]))
981+
})
982+
983+
t.Run("references-sdk-injected-tags", func(t *testing.T) {
984+
tt, ll := testTracer(t)
985+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{
986+
MLApp: "custom-app",
987+
})
988+
span.Annotate(llmobs.SpanAnnotations{
989+
CostTags: []string{"ml_app", "language"},
990+
})
991+
span.Finish(llmobs.FinishSpanConfig{})
992+
993+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
994+
assert.Equal(t, []any{"ml_app", "language"}, costTagsFromSpan(t, llmSpans[0]))
995+
})
996+
997+
t.Run("drops-cost-tags-annotated-after-finish", func(t *testing.T) {
998+
tt, ll := testTracer(t)
999+
span, _ := ll.StartSpan(context.Background(), llmobs.SpanKindLLM, "", llmobs.StartSpanConfig{})
1000+
span.Annotate(llmobs.SpanAnnotations{Tags: map[string]string{"team": "ml"}})
1001+
span.Finish(llmobs.FinishSpanConfig{})
1002+
span.Annotate(llmobs.SpanAnnotations{CostTags: []string{"team"}})
1003+
1004+
llmSpans := tt.WaitForLLMObsSpans(t, 1)
1005+
assert.NotContains(t, llmSpans[0].Meta, "metadata")
1006+
})
1007+
}
1008+
1009+
func costTagsFromSpan(t *testing.T, span llmobstransport.LLMObsSpanEvent) []any {
1010+
t.Helper()
1011+
1012+
metadata, ok := span.Meta["metadata"].(map[string]any)
1013+
require.True(t, ok, "metadata should be present")
1014+
ddMetadata, ok := metadata["_dd"].(map[string]any)
1015+
require.True(t, ok, "metadata._dd should be present")
1016+
costTags, ok := ddMetadata["cost_tags"].([]any)
1017+
require.True(t, ok, "metadata._dd.cost_tags should be present")
1018+
return costTags
1019+
}
1020+
8691021
func TestSpanTruncation(t *testing.T) {
8701022
t.Run("text-input", func(t *testing.T) {
8711023
tt, ll := testTracer(t)

internal/llmobs/span.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package llmobs
88
import (
99
"encoding/json"
1010
"maps"
11+
"slices"
1112
"sync"
1213
"time"
1314

@@ -220,6 +221,9 @@ type SpanAnnotations struct {
220221
Metrics map[string]float64
221222
// Tags contains string tags key-value pairs.
222223
Tags map[string]string
224+
// CostTags contains tag keys to propagate to LLMObs cost and token metrics.
225+
// Each key must reference a tag already present on the span.
226+
CostTags []string
223227
}
224228

225229
// Span represents an LLMObs span with its associated metadata and context.
@@ -358,6 +362,15 @@ func (s *Span) Annotate(a SpanAnnotations) {
358362
}
359363
}
360364

365+
if a.CostTags != nil {
366+
trackCostTagsAnnotated(s, "annotate")
367+
for _, costTag := range a.CostTags {
368+
if !slices.Contains(s.llmCtx.costTags, costTag) {
369+
s.llmCtx.costTags = append(s.llmCtx.costTags, costTag)
370+
}
371+
}
372+
}
373+
361374
if a.Prompt != nil {
362375
if s.spanKind != SpanKindLLM {
363376
log.Warn("llmobs: input prompt can only be annotated on llm spans, ignoring")

internal/llmobs/telemetry.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const (
2626
telemetryMetricAnnotations = "annotations"
2727
telemetryMetricEvalsSubmitted = "evals_submitted"
2828
telemetryMetricUserFlushes = "user_flush"
29+
telemetryMetricCostTagsAnnotated = "cost_tags.annotated"
30+
telemetryMetricCostTagsSubmitted = "cost_tags.submitted"
2931
)
3032

3133
var telemetryErrorTypes = map[error]string{
@@ -143,6 +145,26 @@ func trackSpanAnnotations(span *Span, err error) {
143145
telemetry.Count(telemetry.NamespaceMLObs, telemetryMetricAnnotations, tags).Submit(1)
144146
}
145147

148+
func trackCostTagsAnnotated(span *Span, source string) {
149+
if telemetry.Disabled() {
150+
return
151+
}
152+
153+
telemetry.Count(telemetry.NamespaceMLObs, telemetryMetricCostTagsAnnotated, costTagsTelemetryTags(span, source)).Submit(1)
154+
}
155+
156+
func trackCostTagsSubmitted(span *Span, count int, source, state, reason string) {
157+
if telemetry.Disabled() || count == 0 {
158+
return
159+
}
160+
161+
tags := append(costTagsTelemetryTags(span, source),
162+
"state:"+state,
163+
"reason:"+reason,
164+
)
165+
telemetry.Count(telemetry.NamespaceMLObs, telemetryMetricCostTagsSubmitted, tags).Submit(float64(count))
166+
}
167+
146168
func trackSubmitEvaluationMetric(metric *transport.LLMObsMetric, err error) {
147169
if telemetry.Disabled() {
148170
return
@@ -169,6 +191,29 @@ func trackUserFlush() {
169191
userFlushHandle.Submit(1)
170192
}
171193

194+
func costTagsTelemetryTags(span *Span, source string) []string {
195+
spanKind := "N/A"
196+
mlApp := "N/A"
197+
modelProvider := "N/A"
198+
if span != nil {
199+
if span.spanKind != "" {
200+
spanKind = string(span.spanKind)
201+
}
202+
if span.mlApp != "" {
203+
mlApp = span.mlApp
204+
}
205+
if span.llmCtx.modelProvider != "" {
206+
modelProvider = span.llmCtx.modelProvider
207+
}
208+
}
209+
return []string{
210+
"span_kind:" + spanKind,
211+
"source:" + source,
212+
"ml_app:" + mlApp,
213+
"model_provider:" + modelProvider,
214+
}
215+
}
216+
172217
func spanEventTags(event *transport.LLMObsSpanEvent) []string {
173218
spanKind := "N/A"
174219
if meta, ok := event.Meta["span.kind"]; ok {

internal/telemetry/internal/knownmetrics/known_metrics.common.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)