diff --git a/PARITY-STATUS.md b/PARITY-STATUS.md index d6e90278..8081871c 100644 --- a/PARITY-STATUS.md +++ b/PARITY-STATUS.md @@ -70,6 +70,9 @@ All five servers carry the transport core: frame dispatch, per-turn engine, sess | Shared scenario conformance corpus | ✅ | ✅ | ✅ | ✅ | ✅ | | Postgres conversation store | ✅ | ✅ | ✅ | ✅ | ✅ | | Server `gen_ai.*` OTel telemetry (chat + tool spans · redacted tool args · env-gated OTLP) | ✅ | ✅ | ✅ | ✅ | ✅ | +| └ ingest-joinable spans (`gen_ai.operation.name` · self-identifying tool spans) [^ingest] | ✅ | ✅ | ✅ | ✅ | ✅ | +| └ cost on the span (`gen_ai.usage.cost_usd` / `smooai.gen_ai.cost_unavailable`) | ✅ | ✅ | ✅ | ✅ | ✅ | +| └ usage/cost provenance + `gen_ai.response.id` [^provenance] | ✅ | — | — | — | — | | Second storage backend (DynamoDB + S3 Vectors) | ✅ | — | — | — | — | | Persistent checkpoint / knowledge / ACL-knowledge stores [^knowledge] | ✅ | ✅ | ◐ | ◐ | ◐ | | Deep ingestion + ACL surface | ✅ | ✅ | ◐ | ◐ | ◐ | @@ -77,6 +80,21 @@ All five servers carry the transport core: frame dispatch, per-turn engine, sess | Backplane `publish` (event fan-out) | ✅ | — | ✅ | ✅ | — | | **Cross-pod backplane (Redis / NATS)** | ✅ | — | — | — | — | +[^ingest]: The api-prime OTLP ingest builds a span's attribute set from the resource + attrs plus **that span's own**, with no inheritance from the parent. A tool span + without its own `gen_ai.system` therefore fails the ingest's LLM-event gate and is + **discarded** — Rust's were, for their entire existence (zero rows with + `operation_name = 'tool'`, all time). `gen_ai.operation.name` must be literally + `"chat"` / `"tool"`: the ingest takes the attribute verbatim when present and only + derives it from the span name as a fallback, so any other spelling lands in the + column and matches nothing. + +[^provenance]: Rust-only because it needs engine support that exists only in the Rust + core (1.10.0): `usage_estimated` / `cost_estimated` on `AgentEvent::Completed`, plus + capturing the gateway's `chatcmpl-…` response id. Until the other four cores carry + the same fields, those engines cannot distinguish a measured token count from an + estimated one, and have no join key to `LiteLLM_SpendLogs`. Tracked in th-73c8b5. + [^knowledge]: The durable **knowledge + ACL-knowledge** Postgres stores now ship in **Go, TypeScript and Python** too ([PRs #442, #443, #444](https://github.com/SmooAI/smooth-operator/pulls)) — on the shared `knowledge_vectors` table + pgvector — alongside Rust and .NET (which already had them, e.g. Python's `postgres_knowledge.py`: `PostgresVectorKnowledge` / `PostgresAclKnowledge`). These three cells stay **◐ rather than ✅** for two honest reasons: the persistent **checkpoint** store is still pending in Go/TS/Python, and the **TS + Python** knowledge stores are shipped and contract-tested but **not yet wired into the live dispatcher** (a sync-engine vs async-pg bridge). The **second storage backend** (DynamoDB + S3-Vectors, the row above) remains Rust-only. **The operational consequence:** only the **Rust** server scales past one replica today. Go, TypeScript and Python run an in-memory backplane — correct for a single process, silently wrong the moment you run two pods, because an event published on pod A never reaches a socket held by pod B. C# has no backplane surface at all. diff --git a/dotnet/server/src/Telemetry.cs b/dotnet/server/src/Telemetry.cs index 0faa9b9c..4a58b8ef 100644 --- a/dotnet/server/src/Telemetry.cs +++ b/dotnet/server/src/Telemetry.cs @@ -40,6 +40,46 @@ public static class Telemetry public const string GenAiToolArguments = "gen_ai.tool.call.arguments"; public const string GenAiAgentName = "gen_ai.agent.name"; + /// + /// gen_ai.operation.name — the operation a span represents. + /// + /// The api-prime OTLP ingest takes this attribute VERBATIM when present and only derives it + /// from the span name as a fallback, and its queries filter on operation_name = 'tool'. + /// So the values must be exactly / — + /// a spelling like execute_tool would land in the column and match nothing. + /// + public const string GenAiOperationName = "gen_ai.operation.name"; + + /// + /// gen_ai.usage.cost_usd — the turn's cost in USD. + /// + /// Recorded ONLY when positive. A zero is ambiguous: the gateway answers 0 for a model it has + /// no price for, and local pricing returns the free tier for anything it does not recognise, so + /// a zero means "not measured", never "free". Exporting it would render a paid turn as a + /// confident $0.00. + /// + public const string GenAiUsageCostUsd = "gen_ai.usage.cost_usd"; + + /// smooai.gen_ai.cost_unavailable — why is + /// absent. Set INSTEAD of the cost, never alongside it. Same attribute name and values across + /// every engine so a consumer never special-cases per language. + public const string CostUnavailable = "smooai.gen_ai.cost_unavailable"; + + /// value: no price could be established. + public const string CostUnavailableUnpriced = "unpriced"; + + /// smooai.org_id — the owning org, matching every other engine. + /// ponytail: declared here but never set — the .NET server has no org concept at all + /// (no orgId anywhere in dotnet/server/src), so wiring it is a plumbing change + /// through FrameDispatcher, not a span tag. Set it here once that exists. + public const string SmooaiOrgId = "smooai.org_id"; + + /// value on a span. + public const string OperationChat = "chat"; + + /// value on a span. + public const string OperationTool = "tool"; + /// Span name for the per-turn GenAI chat span (gen_ai.chat). public const string SpanChat = "gen_ai.chat"; diff --git a/dotnet/server/src/TurnRunner.cs b/dotnet/server/src/TurnRunner.cs index 80a35c83..9c7c5ece 100644 --- a/dotnet/server/src/TurnRunner.cs +++ b/dotnet/server/src/TurnRunner.cs @@ -146,13 +146,20 @@ private static string ConfiguredModel() => /// Emit a gen_ai.tool child span (parented to the ambient turn span) for one tool /// call, carrying the tool name and its redacted JSON arguments. No-op when nothing is sampling /// (StartActivity returns null). - private static void EmitToolSpan(FunctionCallContent call) + private static void EmitToolSpan(FunctionCallContent call, string conversationId) { using var toolSpan = Telemetry.Source.StartActivity(Telemetry.SpanTool); if (toolSpan is null) { return; } + // The OTLP ingest builds a span's attributes from the resource attrs plus THAT span's own, + // with no inheritance from the parent — so a child repeats its identifiers or it cannot be + // joined. Omitting gen_ai.system is worse than losing the join: the ingest's LLM-event gate + // keys on it, so bare tool spans are DISCARDED. Rust's were, for their entire existence. + toolSpan.SetTag(Telemetry.GenAiSystem, Telemetry.SystemName); + toolSpan.SetTag(Telemetry.GenAiOperationName, Telemetry.OperationTool); + toolSpan.SetTag(Telemetry.GenAiConversationId, conversationId); toolSpan.SetTag(Telemetry.GenAiToolName, call.Name); var args = call.Arguments is null ? "{}" : JsonSerializer.Serialize(call.Arguments); toolSpan.SetTag(Telemetry.GenAiToolArguments, Telemetry.RedactToolArguments(args)); @@ -226,6 +233,7 @@ public async Task RunAsync(string conversationId, string requestId, // host (env-gated on OTEL_EXPORTER_OTLP_ENDPOINT) or a test's ActivityListener. using var turnActivity = Telemetry.Source.StartActivity(Telemetry.SpanChat); turnActivity?.SetTag(Telemetry.GenAiSystem, Telemetry.SystemName); + turnActivity?.SetTag(Telemetry.GenAiOperationName, Telemetry.OperationChat); turnActivity?.SetTag(Telemetry.GenAiRequestModel, ConfiguredModel()); turnActivity?.SetTag(Telemetry.GenAiConversationId, conversationId); turnActivity?.SetTag(Telemetry.GenAiAgentName, Telemetry.AgentName); @@ -480,7 +488,7 @@ public async Task RunAsync(string conversationId, string requestId, toolNames[call.CallId] = call.Name; // `gen_ai.tool` child span (nests under the turn span), mirroring the Rust // runner emitting one gen_ai.tool span per tool call with redacted args. - EmitToolSpan(call); + EmitToolSpan(call, conversationId); // DEFER a confirmation-gated tool's toolCall chunk: it is emitted from the // gate AFTER write_confirmation_required, so the wire order matches the // canonical (Rust) server. Non-gated tools emit their chunk inline as before. @@ -516,12 +524,31 @@ public async Task RunAsync(string conversationId, string requestId, _interactionPark?.Clear(sessionId); } - // Record token usage on the turn span (omitted when the engine reported none, per the GenAI - // conventions), mirroring the Rust runner's turn_span.record of the usage fields. - if (turnActivity is not null && sawUsage) + // Token counts and cost on the turn span, recording only what was actually measured. + // + // `sawUsage` alone was NOT enough: a usage chunk carrying null counts sets it while both + // totals resolve to 0 via `?? 0`, so this published `input_tokens = 0` on a grounded turn. + // Every other engine guards on the counts themselves. Absent is honest; 0 is a lie. + if (turnActivity is not null) { - turnActivity.SetTag(Telemetry.GenAiUsageInputTokens, promptTokens); - turnActivity.SetTag(Telemetry.GenAiUsageOutputTokens, completionTokens); + if (sawUsage && (promptTokens > 0 || completionTokens > 0)) + { + turnActivity.SetTag(Telemetry.GenAiUsageInputTokens, promptTokens); + turnActivity.SetTag(Telemetry.GenAiUsageOutputTokens, completionTokens); + } + + // Cost is judged separately from the counts: the gateway reports it on an HTTP header + // while usage arrives on an SSE chunk, so either can turn up without the other. A + // non-positive cost becomes an explicit "unpriced" marker, never a confident $0.00. + var costUsd = TurnUsageFrom(agent, sawUsage, promptTokens, completionTokens)?.CostUsd ?? 0; + if (costUsd > 0 && double.IsFinite(costUsd)) + { + turnActivity.SetTag(Telemetry.GenAiUsageCostUsd, costUsd); + } + else + { + turnActivity.SetTag(Telemetry.CostUnavailable, Telemetry.CostUnavailableUnpriced); + } } // 5. Persist the outbound reply. diff --git a/dotnet/server/tests/TelemetryTests.cs b/dotnet/server/tests/TelemetryTests.cs index de6d60d1..39180194 100644 --- a/dotnet/server/tests/TelemetryTests.cs +++ b/dotnet/server/tests/TelemetryTests.cs @@ -121,6 +121,26 @@ public async Task StreamingTurnEmitsGenAiSpansWithModelAndToolArgs() var args = toolSpan.GetTagItem(Telemetry.GenAiToolArguments) as string ?? string.Empty; Assert.Contains("return policy refund window", args); Assert.Equal(chatSpan.Id, toolSpan.ParentId); + + // Being a child is NOT enough. The OTLP ingest builds a span's attributes from + // the resource attrs plus THAT span's own, with no parent inheritance, so the + // tool span repeats the identifiers itself — and without gen_ai.system it fails + // the ingest's LLM-event gate outright and is discarded, which is what happened + // to Rust's tool spans for their entire existence. + Assert.Equal(Telemetry.SystemName, toolSpan.GetTagItem(Telemetry.GenAiSystem)); + Assert.Equal(Telemetry.OperationTool, toolSpan.GetTagItem(Telemetry.GenAiOperationName)); + Assert.Equal(conversationId, toolSpan.GetTagItem(Telemetry.GenAiConversationId)); + + // Must be exactly "chat"/"tool" — the ingest takes the attribute verbatim when + // present and its queries filter on operation_name = 'tool'. + Assert.Equal(Telemetry.OperationChat, chatSpan.GetTagItem(Telemetry.GenAiOperationName)); + + // Cost: exactly one of the two is ever set. This scripted turn is unpriced, so + // the marker must be there INSTEAD of a $0.00 — a missing price must never read + // as free. Before this, .NET was the one engine that shipped a literal + // `new TurnUsage(0, ...)` on the fallback path. + Assert.Null(chatSpan.GetTagItem(Telemetry.GenAiUsageCostUsd)); + Assert.Equal(Telemetry.CostUnavailableUnpriced, chatSpan.GetTagItem(Telemetry.CostUnavailable)); } [Fact] diff --git a/go/server/telemetry.go b/go/server/telemetry.go index f210fe3c..1b2552f8 100644 --- a/go/server/telemetry.go +++ b/go/server/telemetry.go @@ -42,6 +42,34 @@ const ( // SmooaiOrgID is `smooai.org_id` — the owning org. Matches the monorepo TS chat // handler's attribute exactly so the observability studio groups Rust + Go turns by org. SmooaiOrgID = "smooai.org_id" + // GenAIOperationName is `gen_ai.operation.name` — the operation a span represents. + // + // The api-prime OTLP ingest takes this attribute VERBATIM when present and only + // derives it from the span name as a fallback, and its queries filter on + // `operation_name = 'tool'`. So the values must be exactly OperationChat / + // OperationTool — a spelling like "execute_tool" would land in the column and + // match nothing. + GenAIOperationName = "gen_ai.operation.name" + // GenAIUsageCostUSD is `gen_ai.usage.cost_usd` — the turn's cost in USD. + // + // Recorded ONLY when positive. A zero is ambiguous: the gateway answers 0 for a + // model it has no price for, and local pricing returns the free tier for anything + // it does not recognise, so a zero means "not measured", never "free". Exporting + // it would render a paid turn as a confident $0.00. + GenAIUsageCostUSD = "gen_ai.usage.cost_usd" + // CostUnavailable is `smooai.gen_ai.cost_unavailable` — why GenAIUsageCostUSD is + // absent. Set INSTEAD of the cost, never alongside it. Same attribute name and + // values across every engine so a consumer never special-cases per language. + CostUnavailable = "smooai.gen_ai.cost_unavailable" + // CostUnavailableUnpriced is the CostUnavailable value for "no price could be + // established for this model". + CostUnavailableUnpriced = "unpriced" +) + +// OperationChat / OperationTool are the GenAIOperationName values. +const ( + OperationChat = "chat" + OperationTool = "tool" ) // SystemName is emitted for GenAISystem and used as the tracer + service name. diff --git a/go/server/telemetry_test.go b/go/server/telemetry_test.go index f2c5d139..1ebcacbe 100644 --- a/go/server/telemetry_test.go +++ b/go/server/telemetry_test.go @@ -112,6 +112,34 @@ func TestStreamingTurnEmitsGenAISpans(t *testing.T) { t.Errorf("gen_ai.tool span should be a child of gen_ai.chat; parent=%s chat=%s", tool.Parent.SpanID(), chat.SpanContext.SpanID()) } + + // Being a child is NOT enough. The OTLP ingest builds a span's attributes from the + // resource attrs plus THAT span's own, with no parent inheritance, so the tool span + // repeats the identifiers itself — and without gen_ai.system it fails the ingest's + // LLM-event gate outright and is discarded, which is what happened to Rust's tool + // spans for their entire existence (zero rows with operation_name='tool', all time). + assertAttr(t, tool.Attributes, GenAISystem, SystemName) + assertAttr(t, tool.Attributes, GenAIOperationName, OperationTool) + assertAttr(t, tool.Attributes, GenAIConversationID, session.ConversationID) + assertAttr(t, tool.Attributes, SmooaiOrgID, "org-telemetry") + + // Must be exactly "chat"/"tool" — the ingest takes the attribute verbatim when + // present and its queries filter on operation_name = 'tool'. + assertAttr(t, chat.Attributes, GenAIOperationName, OperationChat) + + // Cost: exactly one of the two is ever set. The mock turn IS priced (local + // ModelPricing knows openai/gpt-4o), so the cost lands and the marker must not — + // a zero must never be exported as a real cost, and a real cost must never carry + // an "unavailable" marker beside it. + cost, hasCost := attr(chat.Attributes, GenAIUsageCostUSD) + if !hasCost { + t.Errorf("a priced turn must record %s; got attrs %+v", GenAIUsageCostUSD, chat.Attributes) + } else if cost == "0" || cost == "0.000000" { + t.Errorf("%s must never be exported as zero — that means unpriced, not free", GenAIUsageCostUSD) + } + if _, ok := attr(chat.Attributes, CostUnavailable); ok { + t.Errorf("%s must not be set alongside a real cost", CostUnavailable) + } } func assertAttr(t *testing.T, kvs []attribute.KeyValue, key, want string) { diff --git a/go/server/turn_runner.go b/go/server/turn_runner.go index 4d1d0e0d..bbd3a535 100644 --- a/go/server/turn_runner.go +++ b/go/server/turn_runner.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "math" "os" "strings" "sync/atomic" @@ -200,6 +201,7 @@ func (r *TurnRunner) Run(ctx context.Context, sessionID, conversationID, request tr := otel.Tracer(SystemName) ctx, turnSpan := tr.Start(ctx, SpanChat, oteltrace.WithAttributes( attribute.String(GenAISystem, SystemName), + attribute.String(GenAIOperationName, OperationChat), attribute.String(GenAIRequestModel, r.spanModel()), attribute.String(GenAIConversationID, conversationID), attribute.String(GenAIAgentName, AgentName), @@ -444,19 +446,43 @@ consume: // GenAI conventions) and emit one `gen_ai.tool` child span per tool call — carrying // the redacted arguments, measured latency, and an ERROR status on failure. Mirrors the // Rust runner's post-turn span emission. - if usage != nil && (usage.PromptTokens > 0 || usage.CompletionTokens > 0) { - turnSpan.SetAttributes( - attribute.Int(GenAIUsageInputTokens, usage.PromptTokens), - attribute.Int(GenAIUsageOutputTokens, usage.CompletionTokens), - ) + if usage != nil { + // Counts are omitted when the engine reported none: absent is honest, 0 is a + // lie (a grounded turn always consumes prompt tokens). + if usage.PromptTokens > 0 || usage.CompletionTokens > 0 { + turnSpan.SetAttributes( + attribute.Int(GenAIUsageInputTokens, usage.PromptTokens), + attribute.Int(GenAIUsageOutputTokens, usage.CompletionTokens), + ) + } + // Cost is judged separately from the counts: the gateway reports it on an HTTP + // header while usage arrives on an SSE chunk, so either can turn up without the + // other. A non-positive cost becomes an explicit "unpriced" marker, never $0.00. + if usage.CostUSD > 0 && !math.IsInf(usage.CostUSD, 0) && !math.IsNaN(usage.CostUSD) { + turnSpan.SetAttributes(attribute.Float64(GenAIUsageCostUSD, usage.CostUSD)) + } else { + turnSpan.SetAttributes(attribute.String(CostUnavailable, CostUnavailableUnpriced)) + } } for _, rec := range toolRecords { - _, toolSpan := tr.Start(ctx, SpanTool, oteltrace.WithAttributes( + // The OTLP ingest builds a span's attributes from the resource attrs plus THAT + // span's own, with no inheritance from the parent — so a child repeats its + // identifiers or it cannot be joined. Omitting gen_ai.system is worse than + // losing the join: the ingest's LLM-event gate keys on it, so bare tool spans + // are DISCARDED. Rust's were, for their entire existence. + toolAttrs := []attribute.KeyValue{ + attribute.String(GenAISystem, SystemName), + attribute.String(GenAIOperationName, OperationTool), + attribute.String(GenAIConversationID, conversationID), attribute.String(GenAIToolName, rec.name), attribute.String(GenAIToolArguments, redactToolArguments(rec.arguments)), attribute.Int64("duration_ms", rec.durationMs), attribute.Bool("is_error", rec.isError), - )) + } + if r.orgID != "" { + toolAttrs = append(toolAttrs, attribute.String(SmooaiOrgID, r.orgID)) + } + _, toolSpan := tr.Start(ctx, SpanTool, oteltrace.WithAttributes(toolAttrs...)) if rec.isError { toolSpan.SetStatus(codes.Error, rec.errText) } diff --git a/python/server/src/smooth_operator_server/telemetry.py b/python/server/src/smooth_operator_server/telemetry.py index c3ac9fc8..dee941a4 100644 --- a/python/server/src/smooth_operator_server/telemetry.py +++ b/python/server/src/smooth_operator_server/telemetry.py @@ -56,6 +56,27 @@ #: ``smooai.org_id`` — the owning org. Matches the monorepo TS chat handler so the #: observability studio groups Python + Rust + TS turns by org. SMOOAI_ORG_ID = "smooai.org_id" +#: ``gen_ai.operation.name`` — the operation a span represents. +#: +#: The api-prime OTLP ingest takes this attribute VERBATIM when present and only +#: derives it from the span name as a fallback, and its queries filter on +#: ``operation_name = 'tool'``. So the values must be exactly :data:`OPERATION_CHAT` +#: / :data:`OPERATION_TOOL` — a spelling like ``execute_tool`` would land in the +#: column and match nothing. +GEN_AI_OPERATION_NAME = "gen_ai.operation.name" +#: ``gen_ai.usage.cost_usd`` — the turn's cost in USD. +#: +#: Recorded ONLY when positive. A zero is ambiguous: the gateway answers 0 for a +#: model it has no price for, and local pricing returns the free tier for anything +#: it does not recognise, so a zero means "not measured", never "free". Exporting +#: it would render a paid turn as a confident $0.00. +GEN_AI_USAGE_COST_USD = "gen_ai.usage.cost_usd" +#: ``smooai.gen_ai.cost_unavailable`` — why :data:`GEN_AI_USAGE_COST_USD` is absent. +#: Set INSTEAD of the cost, never alongside it. Same attribute name and values +#: across every engine so a consumer never special-cases per language. +COST_UNAVAILABLE = "smooai.gen_ai.cost_unavailable" +#: :data:`COST_UNAVAILABLE` value: no price could be established for the model. +COST_UNAVAILABLE_UNPRICED = "unpriced" #: The value emitted for :data:`GEN_AI_SYSTEM` — identifies these traces. SYSTEM_NAME = "smooth-operator" @@ -68,6 +89,11 @@ #: Span name for a per-tool-call child span (``gen_ai.tool``). SPAN_TOOL = "gen_ai.tool" +#: :data:`GEN_AI_OPERATION_NAME` value on a :data:`SPAN_CHAT` span. +OPERATION_CHAT = "chat" +#: :data:`GEN_AI_OPERATION_NAME` value on a :data:`SPAN_TOOL` span. +OPERATION_TOOL = "tool" + #: Env var that switches :func:`init_telemetry` to a real OTLP exporter. OTLP_ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_ENDPOINT" diff --git a/python/server/src/smooth_operator_server/turn_runner.py b/python/server/src/smooth_operator_server/turn_runner.py index c072a863..a87f8a37 100644 --- a/python/server/src/smooth_operator_server/turn_runner.py +++ b/python/server/src/smooth_operator_server/turn_runner.py @@ -16,6 +16,7 @@ import contextlib import json import logging +import math import os from dataclasses import dataclass, field, fields from typing import Any, Callable @@ -498,6 +499,7 @@ async def _gate(req: HumanApprovalRequest) -> HumanApprovalResponse: # (zero cost) until `init_telemetry` installs a provider. span_attrs: dict[str, Any] = { telemetry.GEN_AI_SYSTEM: telemetry.SYSTEM_NAME, + telemetry.GEN_AI_OPERATION_NAME: telemetry.OPERATION_CHAT, telemetry.GEN_AI_REQUEST_MODEL: self._model or DEFAULT_MODEL, telemetry.GEN_AI_CONVERSATION_ID: conversation_id, telemetry.GEN_AI_AGENT_NAME: telemetry.AGENT_NAME, @@ -526,7 +528,7 @@ async def _gate(req: HumanApprovalRequest) -> HumanApprovalResponse: # name + redacted arguments (mirrors the Rust runner's per-tool # child span). Emitted for gated tools too — the span is # independent of the deferred wire chunk below. - _emit_tool_span(event) + _emit_tool_span(event, conversation_id, self._org_id) # DEFER a confirmation-gated tool's toolCall chunk: it is emitted # from the gate AFTER `write_confirmation_required`, so the wire # order matches the reference (Rust) server. Non-gated tools emit @@ -545,13 +547,12 @@ async def _gate(req: HumanApprovalRequest) -> HumanApprovalResponse: "promptTokens": event.response.usage.prompt_tokens, "completionTokens": event.response.usage.completion_tokens, } - # Record token usage on the turn span (omitted when the engine - # reported none, per the GenAI conventions). - prompt = event.response.usage.prompt_tokens - completion = event.response.usage.completion_tokens - if prompt or completion: - turn_span.set_attribute(telemetry.GEN_AI_USAGE_INPUT_TOKENS, prompt) - turn_span.set_attribute(telemetry.GEN_AI_USAGE_OUTPUT_TOKENS, completion) + _record_turn_usage( + turn_span, + event.response.usage.prompt_tokens, + event.response.usage.completion_tokens, + event.response.cost_usd, + ) finally: # Turn over: the preamble window is closed for good. Cancel and reap the # task so a still-in-flight preamble can neither emit late nor linger as @@ -750,18 +751,47 @@ def _truncate(value: str, max_chars: int) -> str: return value if len(value) <= max_chars else value[:max_chars] -def _emit_tool_span(event: ToolCallEvent) -> None: +def _record_turn_usage(turn_span: Any, prompt: int, completion: int, cost_usd: float) -> None: + """Record the turn's token counts and cost — only the parts actually measured. + + Counts are omitted when the engine reported none: absent is honest, ``0`` is a lie + (a grounded turn always consumes prompt tokens). Cost is judged separately, because + the gateway reports it on an HTTP header while usage arrives on an SSE chunk — either + can turn up without the other. A non-positive cost becomes an explicit ``unpriced`` + marker rather than a confident ``$0.00``.""" + if prompt or completion: + turn_span.set_attribute(telemetry.GEN_AI_USAGE_INPUT_TOKENS, prompt) + turn_span.set_attribute(telemetry.GEN_AI_USAGE_OUTPUT_TOKENS, completion) + if cost_usd > 0 and math.isfinite(cost_usd): + turn_span.set_attribute(telemetry.GEN_AI_USAGE_COST_USD, cost_usd) + else: + turn_span.set_attribute(telemetry.COST_UNAVAILABLE, telemetry.COST_UNAVAILABLE_UNPRICED) + + +def _emit_tool_span(event: ToolCallEvent, conversation_id: str, org_id: str | None) -> None: """Emit a ``gen_ai.tool`` child span for a tool call, carrying the tool name and the redacted JSON arguments (mirrors the Rust runner's per-tool child span). The span opens as a child of the current ``gen_ai.chat`` turn span and closes immediately — a marker, not a duration measured around execution (the engine owns - tool execution). No-op cost until a tracer provider is installed.""" + tool execution). No-op cost until a tracer provider is installed. + + It repeats the turn's identifiers rather than relying on the parent: the OTLP ingest + builds a span's attributes from the resource attrs plus THAT span's own, with no + parent inheritance. Omitting ``gen_ai.system`` is worse than losing the join — the + ingest's LLM-event gate keys on it, so bare tool spans are DISCARDED. Rust's were, + for their entire existence.""" + attributes: dict[str, Any] = { + telemetry.GEN_AI_SYSTEM: telemetry.SYSTEM_NAME, + telemetry.GEN_AI_OPERATION_NAME: telemetry.OPERATION_TOOL, + telemetry.GEN_AI_CONVERSATION_ID: conversation_id, + telemetry.GEN_AI_TOOL_NAME: event.name, + telemetry.GEN_AI_TOOL_ARGUMENTS: telemetry.redact_tool_arguments(event.arguments or ""), + } + if org_id: + attributes[telemetry.SMOOAI_ORG_ID] = org_id with telemetry.tracer().start_as_current_span( telemetry.SPAN_TOOL, - attributes={ - telemetry.GEN_AI_TOOL_NAME: event.name, - telemetry.GEN_AI_TOOL_ARGUMENTS: telemetry.redact_tool_arguments(event.arguments or ""), - }, + attributes=attributes, ): pass diff --git a/python/server/tests/test_telemetry.py b/python/server/tests/test_telemetry.py index b2057247..0e7599ad 100644 --- a/python/server/tests/test_telemetry.py +++ b/python/server/tests/test_telemetry.py @@ -92,6 +92,28 @@ async def test_turn_emits_gen_ai_spans_with_org_and_tool_args(span_exporter: InM assert "return policy refund window" in args, f"tool args should carry the query; got {args!r}" assert tool.parent is not None and tool.parent.span_id == chat.context.span_id + # Being a child is NOT enough. The OTLP ingest builds a span's attributes from the + # resource attrs plus THAT span's own, with no parent inheritance, so the tool span + # repeats the identifiers itself — and without gen_ai.system it fails the ingest's + # LLM-event gate outright and is discarded, which is what happened to Rust's tool + # spans for their entire existence (zero rows with operation_name='tool', all time). + assert tool.attributes[telemetry.GEN_AI_SYSTEM] == telemetry.SYSTEM_NAME + assert tool.attributes[telemetry.GEN_AI_OPERATION_NAME] == telemetry.OPERATION_TOOL + assert tool.attributes[telemetry.GEN_AI_CONVERSATION_ID] == "conv-otel-srv" + assert tool.attributes[telemetry.SMOOAI_ORG_ID] == "org-telemetry" + + # Must be exactly "chat"/"tool" — the ingest takes the attribute verbatim when + # present and its queries filter on operation_name = 'tool'. + assert chat.attributes[telemetry.GEN_AI_OPERATION_NAME] == telemetry.OPERATION_CHAT + + # Cost: exactly one of the two is ever set, and a zero is never exported as a real + # cost (it means "unpriced", not "free"). + if telemetry.GEN_AI_USAGE_COST_USD in chat.attributes: + assert chat.attributes[telemetry.GEN_AI_USAGE_COST_USD] > 0 + assert telemetry.COST_UNAVAILABLE not in chat.attributes + else: + assert chat.attributes[telemetry.COST_UNAVAILABLE] == telemetry.COST_UNAVAILABLE_UNPRICED + def test_redact_tool_arguments_scrubs_secret_named_keys() -> None: out = telemetry.redact_tool_arguments('{"query":"weather","api_key":"sk-live-123"}') diff --git a/python/server/uv.lock b/python/server/uv.lock index 6ee689b6..7a86a627 100644 --- a/python/server/uv.lock +++ b/python/server/uv.lock @@ -846,7 +846,7 @@ wheels = [ [[package]] name = "smooai-smooth-operator-server" -version = "1.52.3" +version = "1.54.1" source = { editable = "." } dependencies = [ { name = "opentelemetry-api" }, diff --git a/typescript/server/src/telemetry.ts b/typescript/server/src/telemetry.ts index e6d1c133..14ee9169 100644 --- a/typescript/server/src/telemetry.ts +++ b/typescript/server/src/telemetry.ts @@ -26,6 +26,33 @@ export const GEN_AI_TOOL_NAME = 'gen_ai.tool.name'; export const GEN_AI_TOOL_ARGUMENTS = 'gen_ai.tool.call.arguments'; export const GEN_AI_AGENT_NAME = 'gen_ai.agent.name'; export const SMOOAI_ORG_ID = 'smooai.org_id'; +/** + * `gen_ai.operation.name` — the operation a span represents. + * + * The api-prime OTLP ingest takes this attribute VERBATIM when present and only + * derives it from the span name as a fallback, and its queries filter on + * `operation_name = 'tool'`. So the values must be exactly {@link OPERATION_CHAT} + * / {@link OPERATION_TOOL} — a spelling like `execute_tool` would land in the + * column and match nothing. + */ +export const GEN_AI_OPERATION_NAME = 'gen_ai.operation.name'; +/** + * `gen_ai.usage.cost_usd` — the turn's cost in USD. + * + * Recorded ONLY when positive. A zero is ambiguous: the gateway answers `0` for + * a model it has no price for, and local pricing returns the free tier for + * anything it doesn't recognise, so a zero means "not measured", never "free". + * Exporting it would render a paid turn as a confident $0.00. + */ +export const GEN_AI_USAGE_COST_USD = 'gen_ai.usage.cost_usd'; +/** + * `smooai.gen_ai.cost_unavailable` — why {@link GEN_AI_USAGE_COST_USD} is absent. + * Set INSTEAD of the cost, never alongside it. Same attribute name and values + * across every engine so a consumer never special-cases per language. + */ +export const COST_UNAVAILABLE = 'smooai.gen_ai.cost_unavailable'; +/** {@link COST_UNAVAILABLE} value: no price could be established for the model. */ +export const COST_UNAVAILABLE_UNPRICED = 'unpriced'; /** `gen_ai.system` value identifying the polyglot operator to the studio. */ export const SYSTEM_NAME = 'smooth-operator'; @@ -37,6 +64,11 @@ export const SPAN_CHAT = 'gen_ai.chat'; /** Span name for a per-tool-call child span (`gen_ai.tool`). */ export const SPAN_TOOL = 'gen_ai.tool'; +/** {@link GEN_AI_OPERATION_NAME} value on a {@link SPAN_CHAT} span. */ +export const OPERATION_CHAT = 'chat'; +/** {@link GEN_AI_OPERATION_NAME} value on a {@link SPAN_TOOL} span. */ +export const OPERATION_TOOL = 'tool'; + /** Instrumentation-scope name the tracer is fetched under. */ export const TRACER_NAME = SYSTEM_NAME; @@ -108,6 +140,7 @@ export function getTracer(): Tracer { export function startTurnSpan(model: string, conversationId: string, orgId: string | undefined): Span { const attributes: Record = { [GEN_AI_SYSTEM]: SYSTEM_NAME, + [GEN_AI_OPERATION_NAME]: OPERATION_CHAT, [GEN_AI_REQUEST_MODEL]: model, [GEN_AI_CONVERSATION_ID]: conversationId, [GEN_AI_AGENT_NAME]: AGENT_NAME, @@ -116,16 +149,55 @@ export function startTurnSpan(model: string, conversationId: string, orgId: stri return getTracer().startSpan(SPAN_CHAT, { attributes }); } +/** + * Record the turn's token counts and cost on the turn span — only the parts that + * were actually measured. + * + * Counts are omitted entirely when the engine reported none: absent is honest, + * `0` is a lie (a grounded turn always consumes prompt tokens). Cost is judged + * separately, because the gateway reports it on an HTTP header while usage comes + * on an SSE chunk — either can arrive without the other. A non-positive cost + * becomes {@link COST_UNAVAILABLE} rather than a `$0.00`. + */ +export function recordTurnUsage(turnSpan: Span, usage: { promptTokens: number; completionTokens: number; costUsd: number } | undefined): void { + if (!usage) return; + if (usage.promptTokens > 0 || usage.completionTokens > 0) { + turnSpan.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, usage.promptTokens); + turnSpan.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, usage.completionTokens); + } + if (usage.costUsd > 0 && Number.isFinite(usage.costUsd)) { + turnSpan.setAttribute(GEN_AI_USAGE_COST_USD, usage.costUsd); + } else { + turnSpan.setAttribute(COST_UNAVAILABLE, COST_UNAVAILABLE_UNPRICED); + } +} + /** * Emit a child `gen_ai.tool` span for one tool call under `turnSpan`, carrying the * tool name and redacted arguments. `durationMs`, when known, is recorded too. */ -export function recordToolSpan(turnSpan: Span, toolName: string, argumentsJson: string, durationMs?: number): void { +export function recordToolSpan( + turnSpan: Span, + toolName: string, + argumentsJson: string, + conversationId: string, + orgId?: string, + durationMs?: number, +): void { const ctx = trace.setSpan(context.active(), turnSpan); + // The OTLP ingest builds a span's attributes from the resource attrs plus + // THAT span's own, with no inheritance from the parent — so a child repeats + // its identifiers or it cannot be joined. Omitting `gen_ai.system` is worse + // than losing the join: the ingest's LLM-event gate keys on it, so bare tool + // spans are DISCARDED. Rust's were, for their entire existence. const attributes: Record = { + [GEN_AI_SYSTEM]: SYSTEM_NAME, + [GEN_AI_OPERATION_NAME]: OPERATION_TOOL, + [GEN_AI_CONVERSATION_ID]: conversationId, [GEN_AI_TOOL_NAME]: toolName, [GEN_AI_TOOL_ARGUMENTS]: redactToolArguments(argumentsJson), }; + if (orgId) attributes[SMOOAI_ORG_ID] = orgId; if (durationMs !== undefined) attributes.duration_ms = durationMs; getTracer().startSpan(SPAN_TOOL, { attributes }, ctx).end(); } diff --git a/typescript/server/src/turnRunner.ts b/typescript/server/src/turnRunner.ts index ade45e50..59f2c8bb 100644 --- a/typescript/server/src/turnRunner.ts +++ b/typescript/server/src/turnRunner.ts @@ -20,7 +20,7 @@ import type { ModelCeilingResolver } from './modelCeiling.js'; import * as protocol from './protocol.js'; import type { Citation, Frame, TurnUsage } from './protocol.js'; import type { SessionStore } from './sessionStore.js'; -import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, recordToolSpan, startTurnSpan } from './telemetry.js'; +import { recordToolSpan, recordTurnUsage, startTurnSpan } from './telemetry.js'; import { withUserImages, type UserImage } from './toolContext.js'; import { advanceStep, judgeStep, resolveCurrentStep, type ConversationWorkflow } from './workflow.js'; @@ -425,7 +425,7 @@ export class TurnRunner { // Emit a `gen_ai.tool` child span for every tool call the engine makes — // BEFORE the gated `continue` below, so a confirmation-gated tool is // traced too (parity with the Rust runner collecting all tool records). - if (event.type === 'tool_call') recordToolSpan(turnSpan, event.name, event.arguments); + if (event.type === 'tool_call') recordToolSpan(turnSpan, event.name, event.arguments, conversationId, this.orgId); // DEFER a confirmation-gated tool's toolCall chunk: it is emitted from // the gate AFTER `write_confirmation_required`, so the wire order matches // the reference (Rust) server. Non-gated tools emit their chunk inline. @@ -445,12 +445,10 @@ export class TurnRunner { }; } } - // Record token usage on the turn span, omitting a turn that reported none - // (per the GenAI conventions), matching the Rust runner. - if (usage && (usage.promptTokens > 0 || usage.completionTokens > 0)) { - turnSpan.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, usage.promptTokens); - turnSpan.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, usage.completionTokens); - } + // Token counts (omitted when the engine reported none) plus cost and, + // when there is none, an explicit "unpriced" marker. One helper owns + // that policy so it can't drift from the other engines. + recordTurnUsage(turnSpan, usage); } finally { turnSpan.end(); // Turn over: drop any lingering pending confirmation so a stale entry can't diff --git a/typescript/server/test/telemetry.test.ts b/typescript/server/test/telemetry.test.ts index fdb575e1..01c29c42 100644 --- a/typescript/server/test/telemetry.test.ts +++ b/typescript/server/test/telemetry.test.ts @@ -64,6 +64,30 @@ describe('TurnRunner GenAI OTel spans', () => { // The tool span is a child of the turn span (same trace). expect(toolSpan!.spanContext().traceId).toBe(chatSpan!.spanContext().traceId); expect(toolSpan!.parentSpanContext?.spanId).toBe(chatSpan!.spanContext().spanId); + + // (3) Being a child is NOT enough. The OTLP ingest builds a span's attributes + // from the resource attrs plus THAT span's own, with no parent inheritance, so + // the tool span repeats the identifiers itself — and without gen_ai.system it + // fails the ingest's LLM-event gate outright and is discarded, which is what + // happened to Rust's tool spans for their entire existence. + expect(attr(toolSpan!, 'gen_ai.system')).toBe('smooth-operator'); + expect(attr(toolSpan!, 'gen_ai.operation.name')).toBe('tool'); + expect(attr(toolSpan!, 'gen_ai.conversation.id')).toBe(session.conversationId); + expect(attr(toolSpan!, 'smooai.org_id')).toBe('org-telemetry'); + + // Must be exactly 'chat'/'tool' — the ingest takes the attribute verbatim when + // present and its queries filter on operation_name = 'tool'. + expect(attr(chatSpan!, 'gen_ai.operation.name')).toBe('chat'); + + // (4) Cost: exactly one of the two is ever set, and a zero is never exported as + // a real cost (it means "unpriced", not "free"). + const cost = attr(chatSpan!, 'gen_ai.usage.cost_usd'); + if (cost === undefined) { + expect(attr(chatSpan!, 'smooai.gen_ai.cost_unavailable')).toBe('unpriced'); + } else { + expect(Number(cost)).toBeGreaterThan(0); + expect(attr(chatSpan!, 'smooai.gen_ai.cost_unavailable')).toBeUndefined(); + } }); });