diff --git a/CLAUDE.md b/CLAUDE.md index 81b30066..d4246e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -685,6 +685,21 @@ Templates are DB rows seeded by migrations (`traceway-otel-agent` for the OTel h | POST | `/api/exception-stack-traces/by-id/:exceptionId` | App | Single exception by ID | | POST | `/api/exception-stack-traces/:hash` | App | Exception by hash | +**AI Traces & Conversations** + +One `ai_traces` row per LLM call (any OTLP span with `gen_ai.*` attributes). Each row carries a `conversation_id` resolved at ingest (`gen_ai.conversation.id` -> `session.id` span/resource attr -> distributed trace id -> empty), `tool_call_count`/`tool_names` parsed from the completion payload (OpenAI `tool_calls`, Anthropic `tool_use`, OTel output messages, `gen_ai.tool.*` fallback), and `flagged`/`flagged_terms` from an ingest-time word-boundary scan of prompt+completion against per-project selected built-in language packs (`projects.ai_flagged_languages`, default `["en"]`; packs live in `backend/app/services/contentflag/terms/*.txt` — en/de/es/fr/it/pt/sr; empty array = custom terms only) plus per-project custom terms (`projects.ai_flagged_terms`); both edited in the project settings AI tab and cached via the project cache. All three project-creation paths (`Create`, `CreateWithOrganization`, `cmd/seed.go`) must set `AiFlaggedLanguages` explicitly since lit inserts every struct field. `tool_names`/`flagged_terms` are stored comma-separated (values sanitized at ingest); the content matcher lives in `backend/app/services/contentflag/`. Conversation analytics exclude rows with an empty `conversation_id`; user analytics additionally require a non-empty `user_id`. + +| Method | Endpoint | Auth | Purpose | +|--------|----------|------|---------| +| POST | `/api/ai-traces/grouped` | App | AI traces grouped by trace name | +| POST | `/api/ai-traces/trace` | App | Calls for one trace name (`?traceName=`) | +| POST | `/api/ai-traces/:traceId` | App | Single call detail + conversation blob | +| POST | `/api/ai-conversations/grouped` | App | Conversations (GROUP BY conversation_id): turns, cost, tokens, tools, models, flagged; filters userId/model/toolName/flaggedOnly/search (search matches conversation id, user, model, tool names, and flagged terms; row-level filters are semi-joins on conversation_id so a match on any turn returns the whole conversation's aggregates); response also carries `thresholds` (range-wide P95 cost/turns for outlier highlighting) and `facets` (models, tools) | +| POST | `/api/ai-conversations/conversation` | App | All turns of one conversation (id in body) ordered by recorded_at, each with its stored input/output payload (capped at 200 turns of payloads), plus stats | +| POST | `/api/ai-users/grouped` | App | Per-user conversation analytics: conversation count, total calls, avg/min/median turns, avg cost per conversation, total cost, flagged conversation count | + +Frontend routes: `/ai-traces` (tabs: Traces, Conversations, Users), `/ai-traces/conversations/[conversationId]` (chat timeline with tool calls rendered). Trace names `conversations` and `users` are shadowed by these static routes. Notification rule types `ai_trace_cost` (per call), `ai_conversation_cost` (24h cumulative per conversation), and `ai_flagged_content` (flagged term match, optional term filter) are event-driven; remember both `notification_rule.repository.go` copies list event rule types explicitly. + **Organization Management** | Method | Endpoint | Auth | Purpose | |--------|----------|------|---------| diff --git a/README.md b/README.md index d020f806..fef74987 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Traceway is an **OpenTelemetry-native** observability platform. It combines **lo - **Exceptions**: Stack traces are normalized, given a SHA-256 fingerprint, and grouped into ranked issues. Source-mapped (webpack, esbuild, Vite). - **Profiling** _(experimental)_: Flame graphs for CPU, heap, and goroutines with version-to-version diffing and a top-functions table. Ingests native Go pprof and OTLP profiles. - **Session Replay**: Watch what the user did right before the error. Available for web (any JS framework) and Flutter. -- **AI Observability**: LLM cost, tokens, latency, and full conversations across providers (OpenRouter and any OTel-compatible AI gateway). +- **AI Observability**: LLM cost, tokens, latency, and full conversations across providers (OpenRouter and any OTel-compatible AI gateway). Calls group into conversations via `gen_ai.conversation.id`, tool calls are parsed from completions and rendered in the chat view, and multi-language content flagging catches conversations containing terms you care about. Per-customer analytics (conversation length, cost per conversation) key on `user.id`: set it to a stable customer identifier such as your account or tenant id, the same value across all of that user's conversations, never a session id. - **On-Call & Paging**: Rotation schedules with layers and overrides, escalation policies, and pages that escalate until someone acknowledges. Delivered via email, Slack, Pushover, Telegram, or SMS, with one-click acknowledge links that need no login. Plus: background-task (job) monitoring, configurable alerts (Slack / GitHub / email / webhook / Pushover / Telegram), multi-tenant orgs with role-based access, and a per-endpoint slow-threshold override. diff --git a/backend/app/cache/project_cache.go b/backend/app/cache/project_cache.go index 12677305..4cfae358 100644 --- a/backend/app/cache/project_cache.go +++ b/backend/app/cache/project_cache.go @@ -67,6 +67,8 @@ func copyProject(proj *models.Project) *models.Project { cp := *proj cp.HealthcheckPaths = append(models.StringSlice(nil), proj.HealthcheckPaths...) cp.ProfileLabelAllowlist = append(models.StringSlice(nil), proj.ProfileLabelAllowlist...) + cp.AiFlaggedTerms = append(models.StringSlice(nil), proj.AiFlaggedTerms...) + cp.AiFlaggedLanguages = append(models.StringSlice(nil), proj.AiFlaggedLanguages...) return &cp } @@ -120,6 +122,8 @@ func (c *projectCache) UpdateProject(proj *models.Project) { cached.DropHealthyHealthchecks = proj.DropHealthyHealthchecks cached.HealthcheckPaths = proj.HealthcheckPaths cached.ProfileLabelAllowlist = proj.ProfileLabelAllowlist + cached.AiFlaggedTerms = proj.AiFlaggedTerms + cached.AiFlaggedLanguages = proj.AiFlaggedLanguages } func (c *projectCache) RemoveProject(id uuid.UUID) { diff --git a/backend/app/controllers/ai_trace.controller.go b/backend/app/controllers/ai_trace.controller.go index 96bb6f39..3f44d395 100644 --- a/backend/app/controllers/ai_trace.controller.go +++ b/backend/app/controllers/ai_trace.controller.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/url" + "sync" "time" "github.com/gin-gonic/gin" @@ -50,6 +51,57 @@ type AiTraceDetailResponse struct { Conversation json.RawMessage `json:"conversation,omitempty"` } +type AiConversationSearchRequest struct { + FromDate time.Time `json:"fromDate"` + ToDate time.Time `json:"toDate"` + OrderBy string `json:"orderBy"` + SortDirection string `json:"sortDirection"` + Pagination PaginationParams `json:"pagination"` + Search string `json:"search"` + UserId string `json:"userId"` + Model string `json:"model"` + ToolName string `json:"toolName"` + FlaggedOnly bool `json:"flaggedOnly"` +} + +type AiConversationFacets struct { + Models []string `json:"models"` + Tools []string `json:"tools"` +} + +type AiConversationListResponse struct { + Data []models.AiConversationStats `json:"data"` + Pagination Pagination `json:"pagination"` + Thresholds *models.AiConversationThresholds `json:"thresholds"` + Facets AiConversationFacets `json:"facets"` +} + +type AiConversationDetailRequest struct { + ConversationId string `json:"conversationId"` + FromDate time.Time `json:"fromDate"` + ToDate time.Time `json:"toDate"` +} + +type AiConversationTurn struct { + models.AiTrace + Input string `json:"input"` + Output string `json:"output"` +} + +type AiConversationDetailResponse struct { + Data []AiConversationTurn `json:"data"` + Stats *models.AiConversationDetailStats `json:"stats"` +} + +type AiUserSearchRequest struct { + FromDate time.Time `json:"fromDate"` + ToDate time.Time `json:"toDate"` + OrderBy string `json:"orderBy"` + SortDirection string `json:"sortDirection"` + Pagination PaginationParams `json:"pagination"` + Search string `json:"search"` +} + func (a aiTraceController) FindGroupedByTraceName(c *gin.Context) { projectId, err := middleware.GetProjectId(c) if err != nil { @@ -172,4 +224,148 @@ func (a aiTraceController) GetAiTraceDetail(c *gin.Context) { c.JSON(http.StatusOK, response) } +func (a aiTraceController) FindConversations(c *gin.Context) { + projectId, err := middleware.GetProjectId(c) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + var request AiConversationSearchRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + stats, total, thresholds, err := telemetry.AiTraceRepository.FindConversations(c, projectId, request.FromDate, request.ToDate, request.Pagination.Page, request.Pagination.PageSize, request.OrderBy, request.SortDirection, request.Search, request.UserId, request.Model, request.ToolName, request.FlaggedOnly) + if err != nil { + c.AbortWithError(500, traceway.NewStackTraceErrorf("error loading ai conversations: %w", err)) + return + } + + facetModels, err := telemetry.AiTraceRepository.ListModels(c, projectId, request.FromDate, request.ToDate) + if err != nil { + facetModels = nil + traceway.CaptureException(traceway.NewStackTraceErrorf("error loading ai conversation model facets: %w", err)) + } + facetTools, err := telemetry.AiTraceRepository.ListToolNames(c, projectId, request.FromDate, request.ToDate) + if err != nil { + facetTools = nil + traceway.CaptureException(traceway.NewStackTraceErrorf("error loading ai conversation tool facets: %w", err)) + } + + c.JSON(http.StatusOK, AiConversationListResponse{ + Data: stats, + Pagination: Pagination{ + Page: request.Pagination.Page, + PageSize: request.Pagination.PageSize, + Total: total, + TotalPages: (total + int64(request.Pagination.PageSize) - 1) / int64(request.Pagination.PageSize), + }, + Thresholds: thresholds, + Facets: AiConversationFacets{Models: facetModels, Tools: facetTools}, + }) +} + +// conversationPayloadCap bounds how many turns get their stored prompt and +// completion blobs attached on the detail endpoint; each blob can carry a +// full resent message history, so unbounded reads would balloon the response. +const conversationPayloadCap = 200 + +func (a aiTraceController) GetConversationDetail(c *gin.Context) { + projectId, err := middleware.GetProjectId(c) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + var request AiConversationDetailRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if request.ConversationId == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "conversationId is required"}) + return + } + + traces, stats, err := telemetry.AiTraceRepository.FindByConversationId(c, projectId, request.ConversationId, request.FromDate, request.ToDate) + if err != nil { + c.AbortWithError(500, traceway.NewStackTraceErrorf("error loading ai conversation: %w", err)) + return + } + if len(traces) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) + return + } + + turns := make([]AiConversationTurn, len(traces)) + var wg sync.WaitGroup + sem := make(chan struct{}, 8) + for i, trace := range traces { + turns[i] = AiConversationTurn{AiTrace: trace} + if trace.StorageKey == "" || i >= conversationPayloadCap { + continue + } + wg.Add(1) + sem <- struct{}{} + go func(i int, storageKey string) { + defer wg.Done() + defer func() { <-sem }() + data, err := storage.Store.Read(c, storageKey) + if err != nil { + // The blob write is best-effort at ingest, so a missing payload + // is expected for some turns; the turn still renders from its + // row data. + return + } + var payload struct { + Input string `json:"input"` + Output string `json:"output"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return + } + turns[i].Input = payload.Input + turns[i].Output = payload.Output + }(i, trace.StorageKey) + } + wg.Wait() + + c.JSON(http.StatusOK, AiConversationDetailResponse{ + Data: turns, + Stats: stats, + }) +} + +func (a aiTraceController) FindAiUsers(c *gin.Context) { + projectId, err := middleware.GetProjectId(c) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + var request AiUserSearchRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + stats, total, err := telemetry.AiTraceRepository.FindUserStats(c, projectId, request.FromDate, request.ToDate, request.Pagination.Page, request.Pagination.PageSize, request.OrderBy, request.SortDirection, request.Search) + if err != nil { + c.AbortWithError(500, traceway.NewStackTraceErrorf("error loading ai user stats: %w", err)) + return + } + + c.JSON(http.StatusOK, PaginatedResponse[models.AiUserStats]{ + Data: stats, + Pagination: Pagination{ + Page: request.Pagination.Page, + PageSize: request.Pagination.PageSize, + Total: total, + TotalPages: (total + int64(request.Pagination.PageSize) - 1) / int64(request.Pagination.PageSize), + }, + }) +} + var AiTraceController = aiTraceController{} diff --git a/backend/app/controllers/notification_rule.controller.go b/backend/app/controllers/notification_rule.controller.go index 7060e3f2..b96b4c48 100644 --- a/backend/app/controllers/notification_rule.controller.go +++ b/backend/app/controllers/notification_rule.controller.go @@ -36,6 +36,8 @@ var validRuleTypes = map[string]bool{ "impact_score_high": true, "impact_score_medium": true, "ai_trace_cost": true, + "ai_conversation_cost": true, + "ai_flagged_content": true, } func (ctrl *notificationRuleController) List(ctx *gin.Context) { diff --git a/backend/app/controllers/otelcontrollers/otel.controller.go b/backend/app/controllers/otelcontrollers/otel.controller.go index 7d2b098c..8dd1edac 100644 --- a/backend/app/controllers/otelcontrollers/otel.controller.go +++ b/backend/app/controllers/otelcontrollers/otel.controller.go @@ -165,7 +165,14 @@ func (o otelController) ExportTraces(c *gin.Context) { var aiTraceInfos []hooks.AiTraceInfo for _, at := range aiTraces { - aiTraceInfos = append(aiTraceInfos, hooks.AiTraceInfo{TraceName: at.TraceName, TotalCost: at.TotalCost}) + aiTraceInfos = append(aiTraceInfos, hooks.AiTraceInfo{ + TraceName: at.TraceName, + TotalCost: at.TotalCost, + ConversationId: at.ConversationId, + UserId: at.UserId, + Flagged: at.Flagged, + FlaggedTerms: at.FlaggedTerms, + }) } if hasOrg { diff --git a/backend/app/controllers/otelcontrollers/testdata/anthropic_tool_use_ai_trace.json b/backend/app/controllers/otelcontrollers/testdata/anthropic_tool_use_ai_trace.json new file mode 100644 index 00000000..c7f9caa8 --- /dev/null +++ b/backend/app/controllers/otelcontrollers/testdata/anthropic_tool_use_ai_trace.json @@ -0,0 +1,41 @@ +{ + "resourceSpans": [ + { + "resource": { + "attributes": [ + {"key": "service.name", "value": {"stringValue": "support-bot"}}, + {"key": "service.version", "value": {"stringValue": "0.9.0"}} + ] + }, + "scopeSpans": [ + { + "scope": {"name": "support-bot"}, + "spans": [ + { + "attributes": [ + {"key": "trace.name", "value": {"stringValue": "Support Bot"}}, + {"key": "user.id", "value": {"stringValue": "customer-77"}}, + {"key": "session.id", "value": {"stringValue": "sess-9f81"}}, + {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}}, + {"key": "gen_ai.system", "value": {"stringValue": "anthropic"}}, + {"key": "gen_ai.request.model", "value": {"stringValue": "claude-sonnet-5"}}, + {"key": "gen_ai.usage.input_tokens", "value": {"intValue": 300}}, + {"key": "gen_ai.usage.output_tokens", "value": {"intValue": 55}}, + {"key": "gen_ai.response.finish_reasons", "value": {"arrayValue": {"values": [{"stringValue": "tool_use"}]}}}, + {"key": "gen_ai.prompt", "value": {"stringValue": "{\"messages\":[{\"role\":\"user\",\"content\":\"My order never arrived, this is bullshit. Where is it?\"}]}"}}, + {"key": "gen_ai.completion", "value": {"stringValue": "{\"content\":[{\"type\":\"text\",\"text\":\"I'm sorry about the delay. Let me look up your order.\"},{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"lookup_order\",\"input\":{\"order_id\":\"A-123\"}}],\"stop_reason\":\"tool_use\"}"}} + ], + "endTimeUnixNano": "1774926644373000000", + "kind": 3, + "name": "chat claude-sonnet-5", + "spanId": "2b06f22e9f134e70", + "startTimeUnixNano": "1774926642873000000", + "status": {"code": 1}, + "traceId": "bb5ad7a3070fecf76bc3180145d97611" + } + ] + } + ] + } + ] +} diff --git a/backend/app/controllers/otelcontrollers/testdata/anthropic_tool_use_ai_trace.json.golden.json b/backend/app/controllers/otelcontrollers/testdata/anthropic_tool_use_ai_trace.json.golden.json new file mode 100644 index 00000000..d224d00d --- /dev/null +++ b/backend/app/controllers/otelcontrollers/testdata/anthropic_tool_use_ai_trace.json.golden.json @@ -0,0 +1,39 @@ +{ + "endpointCount": 0, + "endpoints": [], + "taskCount": 0, + "spanCount": 0, + "spans": [], + "exceptionCount": 0, + "exceptions": [], + "aiTraceCount": 1, + "aiTraces": [ + { + "traceName": "Support Bot", + "model": "claude-sonnet-5", + "provider": "anthropic", + "operation": "chat", + "inputTokens": 300, + "outputTokens": 55, + "totalTokens": 355, + "cachedTokens": 0, + "reasoningTokens": 0, + "inputCost": 0, + "outputCost": 0, + "totalCost": 0, + "finishReason": "tool_use", + "statusCode": 1, + "conversationId": "sess-9f81", + "toolCallCount": 1, + "toolNames": [ + "lookup_order" + ], + "flagged": true, + "flaggedTerms": [ + "bullshit" + ] + } + ], + "conversationCount": 1, + "allSpansLinked": true +} \ No newline at end of file diff --git a/backend/app/controllers/otelcontrollers/testdata/openai_tool_calls_ai_trace.json b/backend/app/controllers/otelcontrollers/testdata/openai_tool_calls_ai_trace.json new file mode 100644 index 00000000..a547d43e --- /dev/null +++ b/backend/app/controllers/otelcontrollers/testdata/openai_tool_calls_ai_trace.json @@ -0,0 +1,44 @@ +{ + "resourceSpans": [ + { + "resource": { + "attributes": [ + {"key": "service.name", "value": {"stringValue": "agent-service"}}, + {"key": "service.version", "value": {"stringValue": "2.1.0"}} + ] + }, + "scopeSpans": [ + { + "scope": {"name": "agent-service"}, + "spans": [ + { + "attributes": [ + {"key": "trace.name", "value": {"stringValue": "Weather Agent"}}, + {"key": "user.id", "value": {"stringValue": "customer-42"}}, + {"key": "gen_ai.conversation.id", "value": {"stringValue": "conv-2024-abc"}}, + {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}}, + {"key": "gen_ai.system", "value": {"stringValue": "openai"}}, + {"key": "gen_ai.request.model", "value": {"stringValue": "gpt-4o"}}, + {"key": "gen_ai.response.model", "value": {"stringValue": "gpt-4o-2024-08-06"}}, + {"key": "gen_ai.usage.input_tokens", "value": {"intValue": 120}}, + {"key": "gen_ai.usage.output_tokens", "value": {"intValue": 40}}, + {"key": "gen_ai.usage.input_cost", "value": {"doubleValue": 0.0012}}, + {"key": "gen_ai.usage.output_cost", "value": {"doubleValue": 0.0008}}, + {"key": "gen_ai.response.finish_reason", "value": {"stringValue": "tool_calls"}}, + {"key": "gen_ai.prompt", "value": {"stringValue": "{\"messages\":[{\"role\":\"user\",\"content\":\"What's the weather in Paris and what time is it there?\"}]}"}}, + {"key": "gen_ai.completion", "value": {"stringValue": "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}},{\"id\":\"call_2\",\"type\":\"function\",\"function\":{\"name\":\"get_time\",\"arguments\":\"{\\\"tz\\\":\\\"Europe/Paris\\\"}\"}}]}}]}"}} + ], + "endTimeUnixNano": "1774926644373000000", + "kind": 3, + "name": "chat gpt-4o", + "spanId": "1a06f22e9f134e7f", + "startTimeUnixNano": "1774926642873000000", + "status": {"code": 1}, + "traceId": "aa5ad7a3070fecf76bc3180145d97600" + } + ] + } + ] + } + ] +} diff --git a/backend/app/controllers/otelcontrollers/testdata/openai_tool_calls_ai_trace.json.golden.json b/backend/app/controllers/otelcontrollers/testdata/openai_tool_calls_ai_trace.json.golden.json new file mode 100644 index 00000000..fe66927b --- /dev/null +++ b/backend/app/controllers/otelcontrollers/testdata/openai_tool_calls_ai_trace.json.golden.json @@ -0,0 +1,38 @@ +{ + "endpointCount": 0, + "endpoints": [], + "taskCount": 0, + "spanCount": 0, + "spans": [], + "exceptionCount": 0, + "exceptions": [], + "aiTraceCount": 1, + "aiTraces": [ + { + "traceName": "Weather Agent", + "model": "gpt-4o", + "provider": "openai", + "operation": "chat", + "inputTokens": 120, + "outputTokens": 40, + "totalTokens": 160, + "cachedTokens": 0, + "reasoningTokens": 0, + "inputCost": 0.0012, + "outputCost": 0.0008, + "totalCost": 0.002, + "finishReason": "tool_calls", + "statusCode": 1, + "conversationId": "conv-2024-abc", + "toolCallCount": 2, + "toolNames": [ + "get_weather", + "get_time" + ], + "flagged": false, + "flaggedTerms": null + } + ], + "conversationCount": 1, + "allSpansLinked": true +} \ No newline at end of file diff --git a/backend/app/controllers/otelcontrollers/testdata/openrouter_ai_trace.json.golden.json b/backend/app/controllers/otelcontrollers/testdata/openrouter_ai_trace.json.golden.json index 44b00cc9..faabeb74 100644 --- a/backend/app/controllers/otelcontrollers/testdata/openrouter_ai_trace.json.golden.json +++ b/backend/app/controllers/otelcontrollers/testdata/openrouter_ai_trace.json.golden.json @@ -22,7 +22,12 @@ "outputCost": 0.015, "totalCost": 0.02, "finishReason": "stop", - "statusCode": 1 + "statusCode": 1, + "conversationId": "ef5ad7a3-070f-ecf7-6bc3-180145d97678", + "toolCallCount": 0, + "toolNames": null, + "flagged": false, + "flaggedTerms": null } ], "conversationCount": 1, diff --git a/backend/app/controllers/otelcontrollers/trace_converter.go b/backend/app/controllers/otelcontrollers/trace_converter.go index b82df875..3836bcb7 100644 --- a/backend/app/controllers/otelcontrollers/trace_converter.go +++ b/backend/app/controllers/otelcontrollers/trace_converter.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/tracewayapp/traceway/backend/app/controllers/clientcontrollers" "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/services/contentflag" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" commonpb "go.opentelemetry.io/proto/otlp/common/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" @@ -19,6 +20,8 @@ import ( type aiTraceConversation struct { StorageKey string Content []byte + Input string + Output string } type entityKind int @@ -52,6 +55,15 @@ func convertTraces(ctx context.Context, existingProject *models.Project, project ) { suppressEntities := existingProject != nil && clientcontrollers.IsFrontendFramework(existingProject.Framework) + // nil languages falls back to the default pack; a project that disabled + // every pack carries an empty (non-nil) slice and scans custom terms only. + var flagLanguages, customFlagTerms []string + if existingProject != nil { + flagLanguages = existingProject.AiFlaggedLanguages + customFlagTerms = existingProject.AiFlaggedTerms + } + flagMatcher := contentflag.NewMatcher(flagLanguages, customFlagTerms) + for _, rs := range req.ResourceSpans { resourceAttrs := rs.GetResource().GetAttributes() serverName := getStringAttribute(resourceAttrs, "service.name") @@ -232,10 +244,18 @@ func convertTraces(ctx context.Context, existingProject *models.Project, project ) aiTrace.DistributedTraceId = prom.distributedTraceId aiTrace.IsRoot = prom.isRoot - aiTraces = append(aiTraces, aiTrace) + aiTrace.ConversationId = resolveConversationId(spanAttrs, resourceAttrs, prom.distributedTraceId) + var convInput, convOutput string if conv := extractConversation(spanAttrs, projectId, prom.id); conv != nil { + convInput, convOutput = conv.Input, conv.Output aiConversations = append(aiConversations, *conv) } + aiTrace.ToolCallCount, aiTrace.ToolNames = extractToolCalls(spanAttrs, convOutput) + if terms := flagMatcher.Scan(convInput, convOutput); len(terms) > 0 { + aiTrace.Flagged = true + aiTrace.FlaggedTerms = terms + } + aiTraces = append(aiTraces, aiTrace) } } else if !suppressEntities && len(span.ParentSpanId) > 0 { // Non-root, unpromoted span → goes to the generic spans table, @@ -697,7 +717,9 @@ func buildAiTrace( userId := getStringAttribute(attrs, "user.id") finishReason := getStringAttribute(attrs, "gen_ai.response.finish_reason") if finishReason == "" { - finishReason = getStringAttribute(attrs, "gen_ai.response.finish_reasons") + // finish_reasons is an array attribute in the OTel gen_ai conventions; + // getStringValues handles both the scalar and array encodings. + finishReason = strings.Join(getStringValues(attrs, "gen_ai.response.finish_reasons"), ",") } statusCode := uint8(span.Status.GetCode()) @@ -744,6 +766,8 @@ var standardAiAttrPrefixes = []string{ "gen_ai.completion", "gen_ai.response.finish_reason", "gen_ai.response.finish_reasons", + "gen_ai.conversation.id", + "gen_ai.tool.", "trace.name", "trace.input", "trace.output", @@ -808,7 +832,121 @@ func extractConversation(attrs []*commonpb.KeyValue, projectId, traceId uuid.UUI return &aiTraceConversation{ StorageKey: fmt.Sprintf("ai-traces/%s/%s.json", projectId, traceId), Content: data, + Input: input, + Output: output, + } +} + +// resolveConversationId picks the conversation grouping key for an AI trace: +// an explicit gen_ai.conversation.id, else session.id (span first, then +// resource — browser SDKs stamp it on the resource), else the distributed +// trace id so a single agent run still groups its calls. +func resolveConversationId(spanAttrs, resourceAttrs []*commonpb.KeyValue, distributedTraceId *uuid.UUID) string { + if id := getStringAttribute(spanAttrs, "gen_ai.conversation.id"); id != "" { + return id + } + if id := getStringAttribute(spanAttrs, "session.id"); id != "" { + return id + } + if id := getStringAttribute(resourceAttrs, "session.id"); id != "" { + return id + } + if distributedTraceId != nil { + return distributedTraceId.String() + } + return "" +} + +const maxToolNames = 50 + +// extractToolCalls pulls tool-call telemetry out of the completion payload +// (OpenAI choices/tool_calls, Anthropic content/tool_use, OTel gen_ai output +// messages with tool_call parts), falling back to the execute_tool span +// attributes when no payload is available. Names are deduplicated in +// first-seen order; commas are stripped because the names are persisted as a +// comma-separated column. +func extractToolCalls(attrs []*commonpb.KeyValue, output string) (int64, []string) { + count, names := parseToolCallsFromOutput(output) + if count == 0 && getStringAttribute(attrs, "gen_ai.operation.name") == "execute_tool" { + count = 1 + if name := sanitizeToolName(getStringAttribute(attrs, "gen_ai.tool.name")); name != "" { + names = []string{name} + } + } + return count, names +} + +func parseToolCallsFromOutput(output string) (int64, []string) { + if output == "" { + return 0, nil + } + + var count int64 + var names []string + seen := map[string]struct{}{} + record := func(name string) { + count++ + name = sanitizeToolName(name) + if name == "" || len(names) >= maxToolNames { + return + } + if _, dup := seen[name]; dup { + return + } + seen[name] = struct{}{} + names = append(names, name) + } + + type contentPart struct { + Type string `json:"type"` + Name string `json:"name"` + } + var objectShape struct { + // OpenAI-style chat completion response. + Choices []struct { + Message struct { + ToolCalls []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + // Anthropic-style response content blocks. + Content []contentPart `json:"content"` + } + if err := json.Unmarshal([]byte(output), &objectShape); err == nil { + for _, choice := range objectShape.Choices { + for _, call := range choice.Message.ToolCalls { + record(call.Function.Name) + } + } + for _, part := range objectShape.Content { + if part.Type == "tool_use" { + record(part.Name) + } + } + return count, names + } + + // OTel gen_ai output messages: a top-level array of messages with parts. + var messagesShape []struct { + Parts []contentPart `json:"parts"` } + if err := json.Unmarshal([]byte(output), &messagesShape); err == nil { + for _, msg := range messagesShape { + for _, part := range msg.Parts { + if part.Type == "tool_call" || part.Type == "tool_use" { + record(part.Name) + } + } + } + } + return count, names +} + +func sanitizeToolName(name string) string { + return strings.TrimSpace(strings.ReplaceAll(name, ",", "")) } func formatExceptionStackTrace(excType, excMessage, excStacktrace string) string { diff --git a/backend/app/controllers/otelcontrollers/trace_converter_test.go b/backend/app/controllers/otelcontrollers/trace_converter_test.go index 7d6daa3a..7a044acb 100644 --- a/backend/app/controllers/otelcontrollers/trace_converter_test.go +++ b/backend/app/controllers/otelcontrollers/trace_converter_test.go @@ -14,10 +14,10 @@ import ( "github.com/tracewayapp/traceway/backend/app/controllers/clientcontrollers" "github.com/tracewayapp/traceway/backend/app/models" "github.com/tracewayapp/traceway/backend/app/services" - commonpb "go.opentelemetry.io/proto/otlp/common/v1" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" - tracepb "go.opentelemetry.io/proto/otlp/trace/v1" + commonpb "go.opentelemetry.io/proto/otlp/common/v1" resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "google.golang.org/protobuf/encoding/protojson" ) @@ -59,6 +59,12 @@ type snapshotAiTrace struct { TotalCost float64 `json:"totalCost"` FinishReason string `json:"finishReason"` StatusCode uint8 `json:"statusCode"` + + ConversationId string `json:"conversationId"` + ToolCallCount int64 `json:"toolCallCount"` + ToolNames []string `json:"toolNames"` + Flagged bool `json:"flagged"` + FlaggedTerms []string `json:"flaggedTerms"` } type snapshotResult struct { @@ -83,6 +89,8 @@ func TestConvertTraces_Snapshot(t *testing.T) { fixture string }{ {"openrouter_ai_trace", "testdata/openrouter_ai_trace.json"}, + {"openai_tool_calls_ai_trace", "testdata/openai_tool_calls_ai_trace.json"}, + {"anthropic_tool_use_ai_trace", "testdata/anthropic_tool_use_ai_trace.json"}, {"node_better_auth", "testdata/node_better_auth.json"}, {"node_sign_in", "testdata/node_sign_in.json"}, {"spring_boot_exception", "testdata/spring_boot_exception.json"}, @@ -158,6 +166,11 @@ func TestConvertTraces_Snapshot(t *testing.T) { TotalCost: at.TotalCost, FinishReason: at.FinishReason, StatusCode: at.StatusCode, + ConversationId: at.ConversationId, + ToolCallCount: at.ToolCallCount, + ToolNames: at.ToolNames, + Flagged: at.Flagged, + FlaggedTerms: at.FlaggedTerms, } } @@ -423,10 +436,10 @@ func TestTraceIdResolution_CrossScope(t *testing.T) { EndTimeUnixNano: now + 2000000, }, { - TraceId: traceIdBytes, - SpanId: rootSpanId, - Name: "GET /api/test", - Kind: tracepb.Span_SPAN_KIND_SERVER, + TraceId: traceIdBytes, + SpanId: rootSpanId, + Name: "GET /api/test", + Kind: tracepb.Span_SPAN_KIND_SERVER, StartTimeUnixNano: now, EndTimeUnixNano: now + 5000000, Attributes: []*commonpb.KeyValue{ @@ -457,48 +470,48 @@ func TestTraceIdResolution_CrossScope(t *testing.T) { func TestFormatExceptionStackTrace(t *testing.T) { tests := []struct { - name string - excType string - excMessage string + name string + excType string + excMessage string excStacktrace string - want string + want string }{ { - name: "go style - no stacktrace", - excType: "RuntimeError", - excMessage: "something failed", + name: "go style - no stacktrace", + excType: "RuntimeError", + excMessage: "something failed", excStacktrace: "", - want: "RuntimeError: something failed", + want: "RuntimeError: something failed", }, { - name: "go style - with stacktrace that doesn't start with type", - excType: "RuntimeError", - excMessage: "something failed", + name: "go style - with stacktrace that doesn't start with type", + excType: "RuntimeError", + excMessage: "something failed", excStacktrace: "goroutine 1 [running]:\nmain.foo()\n\t/app/main.go:10", - want: "RuntimeError: something failed\ngoroutine 1 [running]:\nmain.foo()\n\t/app/main.go:10", + want: "RuntimeError: something failed\ngoroutine 1 [running]:\nmain.foo()\n\t/app/main.go:10", }, { // Java/JVM OTel agents include the full "Type: message\n\tat ..." in // exception.stacktrace, so we must not prepend a duplicate header. - name: "java style - stacktrace already starts with exception type", - excType: "org.springframework.dao.EmptyResultDataAccessException", - excMessage: "Incorrect result size: expected 1, actual 0", + name: "java style - stacktrace already starts with exception type", + excType: "org.springframework.dao.EmptyResultDataAccessException", + excMessage: "Incorrect result size: expected 1, actual 0", excStacktrace: "org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0\n\tat org.springframework.dao.support.DataAccessUtils.requiredSingleResult(DataAccessUtils.java:90)\n\tat com.example.UserService.getUser(UserService.java:38)", - want: "org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0\n\tat org.springframework.dao.support.DataAccessUtils.requiredSingleResult(DataAccessUtils.java:90)\n\tat com.example.UserService.getUser(UserService.java:38)", + want: "org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0\n\tat org.springframework.dao.support.DataAccessUtils.requiredSingleResult(DataAccessUtils.java:90)\n\tat com.example.UserService.getUser(UserService.java:38)", }, { - name: "java style - type only, no message", - excType: "java.lang.NullPointerException", - excMessage: "", + name: "java style - type only, no message", + excType: "java.lang.NullPointerException", + excMessage: "", excStacktrace: "java.lang.NullPointerException\n\tat com.example.Service.run(Service.java:10)", - want: "java.lang.NullPointerException\n\tat com.example.Service.run(Service.java:10)", + want: "java.lang.NullPointerException\n\tat com.example.Service.run(Service.java:10)", }, { - name: "empty everything", - excType: "", - excMessage: "", + name: "empty everything", + excType: "", + excMessage: "", excStacktrace: "", - want: "unknown exception", + want: "unknown exception", }, } for _, tt := range tests { diff --git a/backend/app/controllers/project.controller.go b/backend/app/controllers/project.controller.go index 71db6be0..f9ddedc8 100644 --- a/backend/app/controllers/project.controller.go +++ b/backend/app/controllers/project.controller.go @@ -11,6 +11,7 @@ import ( "github.com/tracewayapp/traceway/backend/app/outbox" "github.com/tracewayapp/traceway/backend/app/profiling" "github.com/tracewayapp/traceway/backend/app/repositories/transactional" + "github.com/tracewayapp/traceway/backend/app/services/contentflag" "net/http" "regexp" "strings" @@ -59,6 +60,29 @@ var projectNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\s\-_]+$`) // Profile label key validation regex: letters, numbers, and . _ : - var profileLabelKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9._:-]+$`) +func cleanAiFlaggedTerms(in []string) ([]string, string) { + if len(in) > 200 { + return nil, "At most 200 flagged terms are allowed" + } + cleaned := make([]string, 0, len(in)) + seen := make(map[string]struct{}) + for _, term := range in { + term = strings.ToLower(strings.TrimSpace(term)) + if term == "" { + continue + } + if utf8.RuneCountInString(term) > 100 { + return nil, "Flagged terms must be at most 100 characters" + } + if _, dup := seen[term]; dup { + continue + } + seen[term] = struct{}{} + cleaned = append(cleaned, term) + } + return cleaned, "" +} + func cleanProfileLabelAllowlist(in []string) ([]string, string) { if len(in) > 20 { return nil, "At most 20 profile label keys are allowed" @@ -101,6 +125,8 @@ type UpdateProjectRequest struct { DropHealthyHealthchecks *bool `json:"dropHealthyHealthchecks"` HealthcheckPaths *[]string `json:"healthcheckPaths"` ProfileLabelAllowlist *[]string `json:"profileLabelAllowlist"` + AiFlaggedTerms *[]string `json:"aiFlaggedTerms"` + AiFlaggedLanguages *[]string `json:"aiFlaggedLanguages"` } type DeleteProjectRequest struct { @@ -271,6 +297,38 @@ func (p projectController) UpdateProject(c *gin.Context) { profileLabelAllowlist = &cleaned } + var aiFlaggedTerms *[]string + if request.AiFlaggedTerms != nil { + cleaned, errMsg := cleanAiFlaggedTerms(*request.AiFlaggedTerms) + if errMsg != "" { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": errMsg}) + return + } + aiFlaggedTerms = &cleaned + } + + var aiFlaggedLanguages *[]string + if request.AiFlaggedLanguages != nil { + cleaned := make([]string, 0, len(*request.AiFlaggedLanguages)) + seen := make(map[string]struct{}) + for _, lang := range *request.AiFlaggedLanguages { + lang = strings.ToLower(strings.TrimSpace(lang)) + if lang == "" { + continue + } + if !contentflag.IsValidLanguage(lang) { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Unknown flagged-term language pack: " + lang}) + return + } + if _, dup := seen[lang]; dup { + continue + } + seen[lang] = struct{}{} + cleaned = append(cleaned, lang) + } + aiFlaggedLanguages = &cleaned + } + projectId, err := middleware.GetProjectId(c) if err != nil { c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) @@ -278,7 +336,7 @@ func (p projectController) UpdateProject(c *gin.Context) { } project, err := db.ExecuteTransaction(func(tx *sql.Tx) (*models.Project, error) { - return transactional.ProjectRepository.Update(tx, projectId, request.Name, request.Framework, request.DropHealthyHealthchecks, healthcheckPaths, profileLabelAllowlist) + return transactional.ProjectRepository.Update(tx, projectId, request.Name, request.Framework, request.DropHealthyHealthchecks, healthcheckPaths, profileLabelAllowlist, aiFlaggedTerms, aiFlaggedLanguages) }) if err != nil { c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("error updating project: %w", err)) diff --git a/backend/app/controllers/routes.go b/backend/app/controllers/routes.go index 05322a96..75f82cb2 100644 --- a/backend/app/controllers/routes.go +++ b/backend/app/controllers/routes.go @@ -131,6 +131,10 @@ func RegisterControllers(router *gin.RouterGroup) { router.POST("/ai-traces/trace", middleware.UseAppAuth, middleware.RequireProjectAccess, AiTraceController.FindByTraceName) router.POST("/ai-traces/:traceId", middleware.UseAppAuth, middleware.RequireProjectAccess, AiTraceController.GetAiTraceDetail) + router.POST("/ai-conversations/grouped", middleware.UseAppAuth, middleware.RequireProjectAccess, AiTraceController.FindConversations) + router.POST("/ai-conversations/conversation", middleware.UseAppAuth, middleware.RequireProjectAccess, AiTraceController.GetConversationDetail) + router.POST("/ai-users/grouped", middleware.UseAppAuth, middleware.RequireProjectAccess, AiTraceController.FindAiUsers) + router.POST("/distributed-traces/:distributedTraceId", middleware.UseAppAuth, DistributedTraceController.GetDistributedTrace) router.POST("/logs", middleware.UseAppAuth, middleware.RequireProjectAccess, LogController.List) diff --git a/backend/app/hooks/report_hooks.go b/backend/app/hooks/report_hooks.go index 005dd225..20d51e54 100644 --- a/backend/app/hooks/report_hooks.go +++ b/backend/app/hooks/report_hooks.go @@ -7,8 +7,12 @@ import ( ) type AiTraceInfo struct { - TraceName string - TotalCost float64 + TraceName string + TotalCost float64 + ConversationId string + UserId string + Flagged bool + FlaggedTerms []string } type ReportEvent struct { diff --git a/backend/app/migrations/ch/0076_add_conversation_id_to_ai_traces.up.sql b/backend/app/migrations/ch/0076_add_conversation_id_to_ai_traces.up.sql new file mode 100644 index 00000000..b30ce219 --- /dev/null +++ b/backend/app/migrations/ch/0076_add_conversation_id_to_ai_traces.up.sql @@ -0,0 +1 @@ +ALTER TABLE ai_traces ADD COLUMN conversation_id String DEFAULT '' diff --git a/backend/app/migrations/ch/0077_add_conversation_id_index_to_ai_traces.up.sql b/backend/app/migrations/ch/0077_add_conversation_id_index_to_ai_traces.up.sql new file mode 100644 index 00000000..f314ba75 --- /dev/null +++ b/backend/app/migrations/ch/0077_add_conversation_id_index_to_ai_traces.up.sql @@ -0,0 +1 @@ +ALTER TABLE ai_traces ADD INDEX idx_conversation_id conversation_id TYPE bloom_filter(0.001) GRANULARITY 1 diff --git a/backend/app/migrations/ch/0078_add_tool_call_count_to_ai_traces.up.sql b/backend/app/migrations/ch/0078_add_tool_call_count_to_ai_traces.up.sql new file mode 100644 index 00000000..1827900d --- /dev/null +++ b/backend/app/migrations/ch/0078_add_tool_call_count_to_ai_traces.up.sql @@ -0,0 +1 @@ +ALTER TABLE ai_traces ADD COLUMN tool_call_count Int64 DEFAULT 0 diff --git a/backend/app/migrations/ch/0079_add_tool_names_to_ai_traces.up.sql b/backend/app/migrations/ch/0079_add_tool_names_to_ai_traces.up.sql new file mode 100644 index 00000000..400b57ac --- /dev/null +++ b/backend/app/migrations/ch/0079_add_tool_names_to_ai_traces.up.sql @@ -0,0 +1 @@ +ALTER TABLE ai_traces ADD COLUMN tool_names String DEFAULT '' diff --git a/backend/app/migrations/ch/0080_add_flagged_to_ai_traces.up.sql b/backend/app/migrations/ch/0080_add_flagged_to_ai_traces.up.sql new file mode 100644 index 00000000..f803f7c5 --- /dev/null +++ b/backend/app/migrations/ch/0080_add_flagged_to_ai_traces.up.sql @@ -0,0 +1 @@ +ALTER TABLE ai_traces ADD COLUMN flagged UInt8 DEFAULT 0 diff --git a/backend/app/migrations/ch/0081_add_flagged_terms_to_ai_traces.up.sql b/backend/app/migrations/ch/0081_add_flagged_terms_to_ai_traces.up.sql new file mode 100644 index 00000000..fc0f0306 --- /dev/null +++ b/backend/app/migrations/ch/0081_add_flagged_terms_to_ai_traces.up.sql @@ -0,0 +1 @@ +ALTER TABLE ai_traces ADD COLUMN flagged_terms String DEFAULT '' diff --git a/backend/app/migrations/duckdb_telemetry/0002_add_ai_conversation_columns.up.sql b/backend/app/migrations/duckdb_telemetry/0002_add_ai_conversation_columns.up.sql new file mode 100644 index 00000000..cfa119e4 --- /dev/null +++ b/backend/app/migrations/duckdb_telemetry/0002_add_ai_conversation_columns.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE ai_traces ADD COLUMN conversation_id VARCHAR DEFAULT ''; +ALTER TABLE ai_traces ADD COLUMN tool_call_count BIGINT DEFAULT 0; +ALTER TABLE ai_traces ADD COLUMN tool_names VARCHAR DEFAULT ''; +ALTER TABLE ai_traces ADD COLUMN flagged BIGINT DEFAULT 0; +ALTER TABLE ai_traces ADD COLUMN flagged_terms VARCHAR DEFAULT ''; diff --git a/backend/app/migrations/pg/0100_add_ai_flagged_terms_to_projects.up.sql b/backend/app/migrations/pg/0100_add_ai_flagged_terms_to_projects.up.sql new file mode 100644 index 00000000..67fcf622 --- /dev/null +++ b/backend/app/migrations/pg/0100_add_ai_flagged_terms_to_projects.up.sql @@ -0,0 +1 @@ +ALTER TABLE projects ADD COLUMN ai_flagged_terms TEXT NOT NULL DEFAULT '[]' diff --git a/backend/app/migrations/pg/0101_add_ai_flagged_languages_to_projects.up.sql b/backend/app/migrations/pg/0101_add_ai_flagged_languages_to_projects.up.sql new file mode 100644 index 00000000..6820848b --- /dev/null +++ b/backend/app/migrations/pg/0101_add_ai_flagged_languages_to_projects.up.sql @@ -0,0 +1 @@ +ALTER TABLE projects ADD COLUMN ai_flagged_languages TEXT NOT NULL DEFAULT '["en"]' diff --git a/backend/app/migrations/sqlite/0061_add_ai_flagged_terms_to_projects.up.sql b/backend/app/migrations/sqlite/0061_add_ai_flagged_terms_to_projects.up.sql new file mode 100644 index 00000000..67fcf622 --- /dev/null +++ b/backend/app/migrations/sqlite/0061_add_ai_flagged_terms_to_projects.up.sql @@ -0,0 +1 @@ +ALTER TABLE projects ADD COLUMN ai_flagged_terms TEXT NOT NULL DEFAULT '[]' diff --git a/backend/app/migrations/sqlite/0062_add_ai_flagged_languages_to_projects.up.sql b/backend/app/migrations/sqlite/0062_add_ai_flagged_languages_to_projects.up.sql new file mode 100644 index 00000000..6820848b --- /dev/null +++ b/backend/app/migrations/sqlite/0062_add_ai_flagged_languages_to_projects.up.sql @@ -0,0 +1 @@ +ALTER TABLE projects ADD COLUMN ai_flagged_languages TEXT NOT NULL DEFAULT '["en"]' diff --git a/backend/app/migrations/sqlite_telemetry/0017_add_ai_conversation_columns.up.sql b/backend/app/migrations/sqlite_telemetry/0017_add_ai_conversation_columns.up.sql new file mode 100644 index 00000000..dcf901ec --- /dev/null +++ b/backend/app/migrations/sqlite_telemetry/0017_add_ai_conversation_columns.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE ai_traces ADD COLUMN conversation_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE ai_traces ADD COLUMN tool_call_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE ai_traces ADD COLUMN tool_names TEXT NOT NULL DEFAULT ''; +ALTER TABLE ai_traces ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0; +ALTER TABLE ai_traces ADD COLUMN flagged_terms TEXT NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS idx_ai_traces_project_conversation ON ai_traces(project_id, conversation_id); diff --git a/backend/app/models/ai_trace.model.go b/backend/app/models/ai_trace.model.go index ba9cfc29..bea5bb6c 100644 --- a/backend/app/models/ai_trace.model.go +++ b/backend/app/models/ai_trace.model.go @@ -7,32 +7,37 @@ import ( ) type AiTrace struct { - Id uuid.UUID `json:"id" ch:"id"` - ProjectId uuid.UUID `json:"projectId" ch:"project_id"` - RecordedAt time.Time `json:"recordedAt" ch:"recorded_at"` - Duration time.Duration `json:"duration" ch:"duration"` - StatusCode uint8 `json:"statusCode" ch:"status_code"` - Model string `json:"model" ch:"model"` - ResponseModel string `json:"responseModel" ch:"response_model"` - Provider string `json:"provider" ch:"provider"` - Operation string `json:"operation" ch:"operation"` - InputTokens int64 `json:"inputTokens" ch:"input_tokens"` - OutputTokens int64 `json:"outputTokens" ch:"output_tokens"` - TotalTokens int64 `json:"totalTokens" ch:"total_tokens"` - CachedTokens int64 `json:"cachedTokens" ch:"cached_tokens"` - ReasoningTokens int64 `json:"reasoningTokens" ch:"reasoning_tokens"` - InputCost float64 `json:"inputCost" ch:"input_cost"` - OutputCost float64 `json:"outputCost" ch:"output_cost"` - TotalCost float64 `json:"totalCost" ch:"total_cost"` - TraceName string `json:"traceName" ch:"trace_name"` - UserId string `json:"userId" ch:"user_id"` - FinishReason string `json:"finishReason" ch:"finish_reason"` - ServerName string `json:"serverName" ch:"server_name"` - AppVersion string `json:"appVersion" ch:"app_version"` + Id uuid.UUID `json:"id" ch:"id"` + ProjectId uuid.UUID `json:"projectId" ch:"project_id"` + RecordedAt time.Time `json:"recordedAt" ch:"recorded_at"` + Duration time.Duration `json:"duration" ch:"duration"` + StatusCode uint8 `json:"statusCode" ch:"status_code"` + Model string `json:"model" ch:"model"` + ResponseModel string `json:"responseModel" ch:"response_model"` + Provider string `json:"provider" ch:"provider"` + Operation string `json:"operation" ch:"operation"` + InputTokens int64 `json:"inputTokens" ch:"input_tokens"` + OutputTokens int64 `json:"outputTokens" ch:"output_tokens"` + TotalTokens int64 `json:"totalTokens" ch:"total_tokens"` + CachedTokens int64 `json:"cachedTokens" ch:"cached_tokens"` + ReasoningTokens int64 `json:"reasoningTokens" ch:"reasoning_tokens"` + InputCost float64 `json:"inputCost" ch:"input_cost"` + OutputCost float64 `json:"outputCost" ch:"output_cost"` + TotalCost float64 `json:"totalCost" ch:"total_cost"` + TraceName string `json:"traceName" ch:"trace_name"` + UserId string `json:"userId" ch:"user_id"` + FinishReason string `json:"finishReason" ch:"finish_reason"` + ServerName string `json:"serverName" ch:"server_name"` + AppVersion string `json:"appVersion" ch:"app_version"` StorageKey string `json:"storageKey" ch:"storage_key"` Attributes map[string]string `json:"attributes" ch:"attributes"` DistributedTraceId *uuid.UUID `json:"distributedTraceId,omitempty" ch:"distributed_trace_id"` IsRoot bool `json:"isRoot" ch:"is_root"` + ConversationId string `json:"conversationId" ch:"conversation_id"` + ToolCallCount int64 `json:"toolCallCount" ch:"tool_call_count"` + ToolNames []string `json:"toolNames" ch:"tool_names"` + Flagged bool `json:"flagged" ch:"flagged"` + FlaggedTerms []string `json:"flaggedTerms" ch:"flagged_terms"` } type AiTraceStats struct { @@ -50,6 +55,56 @@ type AiTraceStats struct { HasNonRoot bool `json:"hasNonRoot"` } +type AiConversationStats struct { + ConversationId string `json:"conversationId"` + UserId string `json:"userId"` + Turns int64 `json:"turns"` + TotalTokens int64 `json:"totalTokens"` + TotalCost float64 `json:"totalCost"` + ToolCallCount int64 `json:"toolCallCount"` + ToolNames []string `json:"toolNames"` + Models []string `json:"models"` + Flagged bool `json:"flagged"` + FlaggedTerms []string `json:"flaggedTerms"` + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` +} + +// AiConversationThresholds carries range-wide outlier cutoffs for the +// conversations list so the frontend can highlight rows beyond the P95. +type AiConversationThresholds struct { + P95Cost float64 `json:"p95Cost"` + P95Turns float64 `json:"p95Turns"` +} + +type AiConversationDetailStats struct { + Turns int64 `json:"turns"` + TotalTokens int64 `json:"totalTokens"` + TotalCost float64 `json:"totalCost"` + ToolCallCount int64 `json:"toolCallCount"` + AvgDuration float64 `json:"avgDuration"` + Models []string `json:"models"` + Flagged bool `json:"flagged"` + FlaggedTerms []string `json:"flaggedTerms"` + UserId string `json:"userId"` + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` +} + +type AiUserStats struct { + UserId string `json:"userId"` + ConversationCount int64 `json:"conversationCount"` + TotalCalls int64 `json:"totalCalls"` + AvgTurns float64 `json:"avgTurns"` + MinTurns int64 `json:"minTurns"` + MedianTurns float64 `json:"medianTurns"` + AvgCostPerConversation float64 `json:"avgCostPerConversation"` + TotalCost float64 `json:"totalCost"` + FlaggedConversationCount int64 `json:"flaggedConversationCount"` + TotalTokens int64 `json:"totalTokens"` + LastSeen time.Time `json:"lastSeen"` +} + type AiTraceDetailStats struct { Count int64 `json:"count"` AvgDuration float64 `json:"avgDuration"` diff --git a/backend/app/models/project.model.go b/backend/app/models/project.model.go index d4031aeb..50091c3d 100644 --- a/backend/app/models/project.model.go +++ b/backend/app/models/project.model.go @@ -54,6 +54,8 @@ type Project struct { DropHealthyHealthchecks bool `json:"dropHealthyHealthchecks"` HealthcheckPaths StringSlice `json:"healthcheckPaths"` ProfileLabelAllowlist StringSlice `json:"profileLabelAllowlist"` + AiFlaggedTerms StringSlice `json:"aiFlaggedTerms"` + AiFlaggedLanguages StringSlice `json:"aiFlaggedLanguages"` } func (p Project) ToProjectWithBackendUrl() *ProjectWithBackendUrl { diff --git a/backend/app/notifications/ai_conversation_rules_test.go b/backend/app/notifications/ai_conversation_rules_test.go new file mode 100644 index 00000000..166cae42 --- /dev/null +++ b/backend/app/notifications/ai_conversation_rules_test.go @@ -0,0 +1,74 @@ +//go:build !telemetry_ch + +package notifications + +import ( + "reflect" + "strings" + "testing" +) + +func TestMatchFlaggedTerms(t *testing.T) { + flagged := []string{"bullshit", "acmecorp"} + + if got := matchFlaggedTerms(buildTermFilter(nil), flagged); !reflect.DeepEqual(got, flagged) { + t.Errorf("empty filter should pass all terms, got %v", got) + } + if got := matchFlaggedTerms(buildTermFilter([]string{" AcmeCorp "}), flagged); !reflect.DeepEqual(got, []string{"acmecorp"}) { + t.Errorf("filter should normalize and match acmecorp, got %v", got) + } + if got := matchFlaggedTerms(buildTermFilter([]string{"unrelated"}), flagged); got != nil { + t.Errorf("non-matching filter should return nil, got %v", got) + } + if got := matchFlaggedTerms(buildTermFilter([]string{"", " "}), flagged); !reflect.DeepEqual(got, flagged) { + t.Errorf("filter of blank terms behaves as empty, got %v", got) + } +} + +func TestBuildAiConversationCostMessage(t *testing.T) { + warning := buildAiConversationCostMessage("conv-1", 6.0, 5.0, "myproject") + if warning.Severity != SeverityWarning { + t.Errorf("expected warning severity at 1.2x threshold, got %v", warning.Severity) + } + if !strings.Contains(warning.Body, "conv-1") { + t.Errorf("body %q should mention the conversation id", warning.Body) + } + if warning.DedupToken != "conv-1" { + t.Errorf("dedup token = %q, expected conv-1", warning.DedupToken) + } + + critical := buildAiConversationCostMessage("conv-1", 15.0, 5.0, "myproject") + if critical.Severity != SeverityCritical { + t.Errorf("expected critical severity at 3x threshold, got %v", critical.Severity) + } +} + +func TestBuildAiFlaggedContentMessage(t *testing.T) { + msg := buildAiFlaggedContentMessage("conv-9", "user-7", []string{"bullshit"}, "myproject") + if !strings.Contains(msg.Subject, "bullshit") { + t.Errorf("subject %q should list matched terms", msg.Subject) + } + if !strings.Contains(msg.Body, "conv-9") || !strings.Contains(msg.Body, "user-7") { + t.Errorf("body %q should mention conversation and user", msg.Body) + } + if !strings.Contains(msg.URL, "flagged=1") { + t.Errorf("url %q should deep-link to the flagged filter", msg.URL) + } + + anonymous := buildAiFlaggedContentMessage("", "", []string{"crap"}, "myproject") + if !strings.Contains(anonymous.Body, "An AI conversation matched") { + t.Errorf("body %q should fall back to the generic phrasing", anonymous.Body) + } +} + +func TestAiDedupKeysAreDistinct(t *testing.T) { + convCost := aiConversationCostDedupKey(7, "abc") + flaggedKey := aiFlaggedContentDedupKey(7, "abc") + traceCost := aiCostDedupKey(7, "abc") + if convCost == flaggedKey || convCost == traceCost || flaggedKey == traceCost { + t.Errorf("dedup keys must not collide: %q %q %q", convCost, flaggedKey, traceCost) + } + if !strings.HasPrefix(convCost, ruleStatePrefix(7)) || !strings.HasPrefix(flaggedKey, ruleStatePrefix(7)) { + t.Error("dedup keys must carry the rule-state prefix so ClearRuleState purges them") + } +} diff --git a/backend/app/notifications/cooldown.go b/backend/app/notifications/cooldown.go index e5ba5e2c..044fc03c 100644 --- a/backend/app/notifications/cooldown.go +++ b/backend/app/notifications/cooldown.go @@ -45,6 +45,14 @@ func aiCostDedupKey(ruleId int, traceName string) string { return ruleStatePrefix(ruleId) + "ai_cost:" + traceName } +func aiConversationCostDedupKey(ruleId int, conversationId string) string { + return ruleStatePrefix(ruleId) + "ai_conv_cost:" + conversationId +} + +func aiFlaggedContentDedupKey(ruleId int, subject string) string { + return ruleStatePrefix(ruleId) + "ai_flagged:" + subject +} + func ClearRuleState(ruleId int) { cooldowns.mu.Lock() delete(cooldowns.fired, ruleId) diff --git a/backend/app/notifications/evaluator_helpers.go b/backend/app/notifications/evaluator_helpers.go index dde1770c..bfe6266b 100644 --- a/backend/app/notifications/evaluator_helpers.go +++ b/backend/app/notifications/evaluator_helpers.go @@ -55,6 +55,10 @@ func evaluateEventRules(event hooks.ReportEvent) { evaluateErrorRegression(ctx, rule, event) case "ai_trace_cost": evaluateAiTraceCostEvent(rule, event) + case "ai_conversation_cost": + evaluateAiConversationCostEvent(ctx, rule, event) + case "ai_flagged_content": + evaluateAiFlaggedContentEvent(rule, event) } } } @@ -99,6 +103,128 @@ func evaluateAiTraceCostEvent(rule *models.NotificationRuleWithChannel, event ho } } +type aiConversationCostConfig struct { + ThresholdCost float64 `json:"thresholdCost"` +} + +func evaluateAiConversationCostEvent(ctx context.Context, rule *models.NotificationRuleWithChannel, event hooks.ReportEvent) { + var cfg aiConversationCostConfig + if err := json.Unmarshal(rule.Config, &cfg); err != nil { + return + } + if cfg.ThresholdCost <= 0 { + return + } + + cooldown := time.Duration(rule.CooldownMinutes) * time.Minute + var candidateIds []string + seen := map[string]struct{}{} + for _, at := range event.AiTraces { + if at.ConversationId == "" { + continue + } + if _, dup := seen[at.ConversationId]; dup { + continue + } + seen[at.ConversationId] = struct{}{} + if dedup.isDuplicate(aiConversationCostDedupKey(rule.Id, at.ConversationId), cooldown) { + continue + } + candidateIds = append(candidateIds, at.ConversationId) + } + if len(candidateIds) == 0 { + return + } + + costs, err := telemetry.AiTraceRepository.GetConversationCosts(ctx, event.ProjectId, candidateIds, time.Now().Add(-24*time.Hour)) + if err != nil { + traceway.CaptureException(fmt.Errorf("failed to load conversation costs for notification: %w", err)) + return + } + + projectName := getProjectName(event.ProjectId) + for _, conversationId := range candidateIds { + cost := costs[conversationId] + if cost < cfg.ThresholdCost { + continue + } + msg := buildAiConversationCostMessage(conversationId, cost, cfg.ThresholdCost, projectName) + // Record before dispatch: a persistently failing dispatch retries once + // per cooldown window, never on every ingest event. + dedup.record(aiConversationCostDedupKey(rule.Id, conversationId)) + dispatch(rule, msg) + } +} + +type aiFlaggedContentConfig struct { + Terms []string `json:"terms"` +} + +func evaluateAiFlaggedContentEvent(rule *models.NotificationRuleWithChannel, event hooks.ReportEvent) { + var cfg aiFlaggedContentConfig + if err := json.Unmarshal(rule.Config, &cfg); err != nil { + return + } + + filter := buildTermFilter(cfg.Terms) + cooldown := time.Duration(rule.CooldownMinutes) * time.Minute + projectName := "" + + for _, at := range event.AiTraces { + if !at.Flagged { + continue + } + matched := matchFlaggedTerms(filter, at.FlaggedTerms) + if len(matched) == 0 { + continue + } + + subject := at.ConversationId + if subject == "" { + subject = at.TraceName + } + dedupKey := aiFlaggedContentDedupKey(rule.Id, subject) + if dedup.isDuplicate(dedupKey, cooldown) { + continue + } + if projectName == "" { + projectName = getProjectName(event.ProjectId) + } + msg := buildAiFlaggedContentMessage(at.ConversationId, at.UserId, matched, projectName) + msg.DedupToken = subject + dedup.record(dedupKey) + dispatch(rule, msg) + } +} + +// buildTermFilter normalizes the rule's configured terms. An empty config +// means the rule fires on any flagged call. +func buildTermFilter(terms []string) map[string]struct{} { + filter := map[string]struct{}{} + for _, term := range terms { + term = strings.ToLower(strings.TrimSpace(term)) + if term != "" { + filter[term] = struct{}{} + } + } + return filter +} + +// matchFlaggedTerms returns the flagged terms that pass the filter, or all of +// them when the filter is empty. +func matchFlaggedTerms(filter map[string]struct{}, flaggedTerms []string) []string { + if len(filter) == 0 { + return flaggedTerms + } + var matched []string + for _, term := range flaggedTerms { + if _, ok := filter[term]; ok { + matched = append(matched, term) + } + } + return matched +} + func countOccurrences(hashes []string) map[string]int { occurrences := make(map[string]int, len(hashes)) for _, h := range hashes { diff --git a/backend/app/notifications/messages.go b/backend/app/notifications/messages.go index 99430021..7795359d 100644 --- a/backend/app/notifications/messages.go +++ b/backend/app/notifications/messages.go @@ -198,6 +198,37 @@ func buildAiTraceCostMessage(traceName string, cost float64, threshold float64, } } +func buildAiConversationCostMessage(conversationId string, cost, threshold float64, projectName string) Message { + severity := SeverityWarning + if cost >= threshold*3 { + severity = SeverityCritical + } + return Message{ + Subject: fmt.Sprintf("[%s] AI conversation cost %s exceeds %s", projectName, formatCostForMessage(cost), formatCostForMessage(threshold)), + Body: fmt.Sprintf("The AI conversation \"%s\" has cost %s over the last 24 hours, exceeding the threshold of %s.", conversationId, formatCostForMessage(cost), formatCostForMessage(threshold)), + Severity: severity, + URL: "/ai-traces/conversations?preset=24h", + DedupToken: conversationId, + } +} + +func buildAiFlaggedContentMessage(conversationId, userId string, terms []string, projectName string) Message { + termList := strings.Join(terms, ", ") + body := fmt.Sprintf("An AI conversation matched flagged terms: %s.", termList) + if conversationId != "" { + body = fmt.Sprintf("AI conversation \"%s\" matched flagged terms: %s.", conversationId, termList) + } + if userId != "" { + body += fmt.Sprintf(" User: %s.", userId) + } + return Message{ + Subject: fmt.Sprintf("[%s] AI conversation flagged: %s", projectName, termList), + Body: body, + Severity: SeverityWarning, + URL: "/ai-traces/conversations?preset=24h&flagged=1", + } +} + type ExceptionDetails struct { Id string Hash string diff --git a/backend/app/repositories/telemetry/ai_trace_repository_test.go b/backend/app/repositories/telemetry/ai_trace_repository_test.go index 7ef1492d..9342659f 100644 --- a/backend/app/repositories/telemetry/ai_trace_repository_test.go +++ b/backend/app/repositories/telemetry/ai_trace_repository_test.go @@ -60,3 +60,450 @@ func TestAiTraceRepository_GetTraceNameStats_EmptyWindow(t *testing.T) { t.Errorf("expected total tokens 0, got %d", stats.TotalTokens) } } + +func makeConversationTrace(projectId uuid.UUID, conversationId, userId, model string, totalCost float64, toolNames []string, flaggedTerms []string, recordedAt time.Time) models.AiTrace { + trace := makeAiTrace(projectId, "agent", 100*time.Millisecond, 100, totalCost, recordedAt) + trace.ConversationId = conversationId + trace.UserId = userId + trace.Model = model + trace.ToolCallCount = int64(len(toolNames)) + trace.ToolNames = toolNames + trace.Flagged = len(flaggedTerms) > 0 + trace.FlaggedTerms = flaggedTerms + return trace +} + +func TestAiTraceRepository_ConversationFieldsRoundTrip(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + trace := makeConversationTrace(projectId, "conv-1", "user-1", "gpt-4o", 0.5, []string{"get_weather", "get_time"}, []string{"bullshit"}, now) + if err := AiTraceRepository.InsertAsync(ctx, []models.AiTrace{trace}); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + found, err := AiTraceRepository.FindById(ctx, projectId, trace.Id, nil) + if err != nil { + t.Fatalf("FindById failed: %v", err) + } + if found == nil { + t.Fatal("trace not found after insert") + } + if found.ConversationId != "conv-1" { + t.Errorf("ConversationId = %q, expected conv-1", found.ConversationId) + } + if found.ToolCallCount != 2 { + t.Errorf("ToolCallCount = %d, expected 2", found.ToolCallCount) + } + if len(found.ToolNames) != 2 || found.ToolNames[0] != "get_weather" || found.ToolNames[1] != "get_time" { + t.Errorf("ToolNames = %v, expected [get_weather get_time]", found.ToolNames) + } + if !found.Flagged { + t.Error("Flagged = false, expected true") + } + if len(found.FlaggedTerms) != 1 || found.FlaggedTerms[0] != "bullshit" { + t.Errorf("FlaggedTerms = %v, expected [bullshit]", found.FlaggedTerms) + } +} + +func TestAiTraceRepository_FindConversations(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + traces := []models.AiTrace{ + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 1.0, []string{"get_weather"}, nil, now), + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o-mini", 2.0, []string{"get_time"}, []string{"damn"}, now.Add(time.Minute)), + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 3.0, []string{"get_weather"}, nil, now.Add(2*time.Minute)), + makeConversationTrace(projectId, "conv-b", "user-2", "gpt-4o", 0.5, nil, nil, now), + // Legacy row without a conversation id must be excluded from grouping. + makeConversationTrace(projectId, "", "user-3", "gpt-4o", 9.0, nil, nil, now), + } + if err := AiTraceRepository.InsertAsync(ctx, traces); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + from, to := now.Add(-time.Hour), now.Add(time.Hour) + stats, total, thresholds, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "total_cost", "desc", "", "", "", "", false) + if err != nil { + t.Fatalf("FindConversations failed: %v", err) + } + if total != 2 { + t.Fatalf("expected 2 conversations, got %d", total) + } + if len(stats) != 2 { + t.Fatalf("expected 2 rows, got %d", len(stats)) + } + if thresholds == nil { + t.Fatal("expected thresholds, got nil") + } + + convA := stats[0] + if convA.ConversationId != "conv-a" { + t.Fatalf("expected conv-a first when sorted by cost, got %q", convA.ConversationId) + } + if convA.Turns != 3 { + t.Errorf("conv-a turns = %d, expected 3", convA.Turns) + } + assertApproxEqual(t, "conv-a TotalCost", convA.TotalCost, 6.0, 0.001) + if convA.UserId != "user-1" { + t.Errorf("conv-a userId = %q, expected user-1", convA.UserId) + } + if convA.ToolCallCount != 3 { + t.Errorf("conv-a toolCallCount = %d, expected 3", convA.ToolCallCount) + } + if len(convA.ToolNames) != 2 { + t.Errorf("conv-a toolNames = %v, expected union of 2 names", convA.ToolNames) + } + if len(convA.Models) != 2 { + t.Errorf("conv-a models = %v, expected 2 distinct models", convA.Models) + } + if !convA.Flagged { + t.Error("conv-a should be flagged (one turn matched)") + } + if len(convA.FlaggedTerms) != 1 || convA.FlaggedTerms[0] != "damn" { + t.Errorf("conv-a flaggedTerms = %v, expected [damn]", convA.FlaggedTerms) + } + if !convA.LastSeen.After(convA.FirstSeen) { + t.Errorf("conv-a lastSeen %v should be after firstSeen %v", convA.LastSeen, convA.FirstSeen) + } + + if stats[1].ConversationId != "conv-b" || stats[1].Turns != 1 { + t.Errorf("conv-b row wrong: %+v", stats[1]) + } + if stats[1].Flagged { + t.Error("conv-b should not be flagged") + } +} + +func TestAiTraceRepository_FindConversations_Filters(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + traces := []models.AiTrace{ + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 1.0, nil, []string{"crap"}, now), + makeConversationTrace(projectId, "conv-b", "user-2", "claude-sonnet-5", 2.0, nil, nil, now), + makeConversationTrace(projectId, "conv-c", "user-2", "gpt-4o", 3.0, nil, nil, now), + } + if err := AiTraceRepository.InsertAsync(ctx, traces); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + from, to := now.Add(-time.Hour), now.Add(time.Hour) + + flagged, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "last_seen", "desc", "", "", "", "", true) + if err != nil { + t.Fatalf("FindConversations flaggedOnly failed: %v", err) + } + if total != 1 || len(flagged) != 1 || flagged[0].ConversationId != "conv-a" { + t.Errorf("flaggedOnly expected only conv-a, got total=%d rows=%+v", total, flagged) + } + + byUser, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "last_seen", "desc", "", "user-2", "", "", false) + if err != nil { + t.Fatalf("FindConversations userId failed: %v", err) + } + if total != 2 || len(byUser) != 2 { + t.Errorf("userId filter expected 2 conversations, got total=%d", total) + } + + byModel, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "last_seen", "desc", "", "", "claude-sonnet-5", "", false) + if err != nil { + t.Fatalf("FindConversations model failed: %v", err) + } + if total != 1 || len(byModel) != 1 || byModel[0].ConversationId != "conv-b" { + t.Errorf("model filter expected conv-b, got total=%d rows=%+v", total, byModel) + } + + bySearch, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "last_seen", "desc", "CONV-C", "", "", "", false) + if err != nil { + t.Fatalf("FindConversations search failed: %v", err) + } + if total != 1 || len(bySearch) != 1 || bySearch[0].ConversationId != "conv-c" { + t.Errorf("search expected conv-c, got total=%d rows=%+v", total, bySearch) + } + + paged, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 2, 2, "total_cost", "desc", "", "", "", "", false) + if err != nil { + t.Fatalf("FindConversations pagination failed: %v", err) + } + if total != 3 || len(paged) != 1 { + t.Errorf("page 2 of size 2 expected 1 row of 3 total, got total=%d rows=%d", total, len(paged)) + } +} + +func TestAiTraceRepository_FindConversations_ToolFilterAndSearch(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + traces := []models.AiTrace{ + // conv-a: 3 turns, only the middle one used a tool. + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 1.0, nil, nil, now), + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 2.0, []string{"ask_math_agent", "calculate"}, nil, now.Add(time.Minute)), + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 3.0, nil, nil, now.Add(2*time.Minute)), + // conv-b: uses get_weather; name contains "calc" nowhere. + makeConversationTrace(projectId, "conv-b", "user-2", "gpt-4o", 0.5, []string{"get_weather"}, nil, now), + } + if err := AiTraceRepository.InsertAsync(ctx, traces); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + from, to := now.Add(-time.Hour), now.Add(time.Hour) + + // Tool filter matches a single turn but returns the whole conversation's + // aggregates. + byTool, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "last_seen", "desc", "", "", "", "calculate", false) + if err != nil { + t.Fatalf("FindConversations toolName failed: %v", err) + } + if total != 1 || len(byTool) != 1 || byTool[0].ConversationId != "conv-a" { + t.Fatalf("toolName filter expected conv-a, got total=%d rows=%+v", total, byTool) + } + if byTool[0].Turns != 3 { + t.Errorf("tool-filtered conversation should keep all 3 turns, got %d", byTool[0].Turns) + } + assertApproxEqual(t, "tool-filtered TotalCost", byTool[0].TotalCost, 6.0, 0.001) + + // A partial tool name must not match as an exact tool filter... + byPartialTool, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "last_seen", "desc", "", "", "", "calc", false) + if err != nil { + t.Fatalf("FindConversations partial toolName failed: %v", err) + } + if total != 0 || len(byPartialTool) != 0 { + t.Errorf("partial tool name should not match exact filter, got total=%d", total) + } + + // ...but free-text search covers tool names (and models) as substrings. + bySearch, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "last_seen", "desc", "math", "", "", "", false) + if err != nil { + t.Fatalf("FindConversations search failed: %v", err) + } + if total != 1 || len(bySearch) != 1 || bySearch[0].ConversationId != "conv-a" { + t.Errorf("search 'math' expected conv-a via tool name, got total=%d rows=%+v", total, bySearch) + } + if len(bySearch) == 1 && bySearch[0].Turns != 3 { + t.Errorf("searched conversation should keep all 3 turns, got %d", bySearch[0].Turns) + } + + byModelSearch, total, _, err := AiTraceRepository.FindConversations(ctx, projectId, from, to, 1, 50, "last_seen", "desc", "GPT-4O", "", "", "", false) + if err != nil { + t.Fatalf("FindConversations model search failed: %v", err) + } + if total != 2 { + t.Errorf("search 'GPT-4O' expected both conversations, got total=%d rows=%+v", total, byModelSearch) + } +} + +func TestAiTraceRepository_ListToolNames(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + traces := []models.AiTrace{ + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 1.0, []string{"get_weather", "calculate"}, nil, now), + makeConversationTrace(projectId, "conv-b", "user-2", "gpt-4o", 1.0, []string{"get_weather"}, nil, now), + makeConversationTrace(projectId, "conv-c", "user-2", "gpt-4o", 1.0, nil, nil, now), + } + if err := AiTraceRepository.InsertAsync(ctx, traces); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + tools, err := AiTraceRepository.ListToolNames(ctx, projectId, now.Add(-time.Hour), now.Add(time.Hour)) + if err != nil { + t.Fatalf("ListToolNames failed: %v", err) + } + if len(tools) != 2 || tools[0] != "calculate" || tools[1] != "get_weather" { + t.Errorf("ListToolNames = %v, expected [calculate get_weather]", tools) + } +} + +func TestAiTraceRepository_FindByConversationId(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + traces := []models.AiTrace{ + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 2.0, []string{"get_time"}, nil, now.Add(time.Minute)), + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 1.0, []string{"get_weather"}, []string{"damn"}, now), + makeConversationTrace(projectId, "conv-other", "user-2", "gpt-4o", 5.0, nil, nil, now), + } + if err := AiTraceRepository.InsertAsync(ctx, traces); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + turns, stats, err := AiTraceRepository.FindByConversationId(ctx, projectId, "conv-a", time.Time{}, time.Time{}) + if err != nil { + t.Fatalf("FindByConversationId failed: %v", err) + } + if len(turns) != 2 { + t.Fatalf("expected 2 turns, got %d", len(turns)) + } + if !turns[0].RecordedAt.Before(turns[1].RecordedAt) { + t.Error("turns should be ordered by recorded_at ASC") + } + if stats.Turns != 2 { + t.Errorf("stats.Turns = %d, expected 2", stats.Turns) + } + assertApproxEqual(t, "stats.TotalCost", stats.TotalCost, 3.0, 0.001) + if stats.ToolCallCount != 2 { + t.Errorf("stats.ToolCallCount = %d, expected 2", stats.ToolCallCount) + } + if !stats.Flagged || len(stats.FlaggedTerms) != 1 { + t.Errorf("stats should be flagged with one term, got flagged=%v terms=%v", stats.Flagged, stats.FlaggedTerms) + } + if stats.UserId != "user-1" { + t.Errorf("stats.UserId = %q, expected user-1", stats.UserId) + } + + empty, _, err := AiTraceRepository.FindByConversationId(ctx, projectId, "missing", time.Time{}, time.Time{}) + if err != nil { + t.Fatalf("FindByConversationId for missing id failed: %v", err) + } + if len(empty) != 0 { + t.Errorf("expected no turns for missing conversation, got %d", len(empty)) + } +} + +func TestAiTraceRepository_FindUserStats(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + traces := []models.AiTrace{ + // user-1: conversations of 1, 2 and 5 turns (median 2, min 1, avg 8/3). + makeConversationTrace(projectId, "u1-c1", "user-1", "gpt-4o", 1.0, nil, nil, now), + makeConversationTrace(projectId, "u1-c2", "user-1", "gpt-4o", 1.0, nil, nil, now), + makeConversationTrace(projectId, "u1-c2", "user-1", "gpt-4o", 1.0, nil, []string{"crap"}, now.Add(time.Minute)), + makeConversationTrace(projectId, "u1-c3", "user-1", "gpt-4o", 1.0, nil, nil, now), + makeConversationTrace(projectId, "u1-c3", "user-1", "gpt-4o", 1.0, nil, nil, now.Add(time.Minute)), + makeConversationTrace(projectId, "u1-c3", "user-1", "gpt-4o", 1.0, nil, nil, now.Add(2*time.Minute)), + makeConversationTrace(projectId, "u1-c3", "user-1", "gpt-4o", 1.0, nil, nil, now.Add(3*time.Minute)), + makeConversationTrace(projectId, "u1-c3", "user-1", "gpt-4o", 1.0, nil, nil, now.Add(4*time.Minute)), + // user-2: one conversation of 2 turns costing 4.0 total. + makeConversationTrace(projectId, "u2-c1", "user-2", "gpt-4o", 3.0, nil, nil, now), + makeConversationTrace(projectId, "u2-c1", "user-2", "gpt-4o", 1.0, nil, nil, now.Add(time.Minute)), + // Rows without user or conversation ids are excluded. + makeConversationTrace(projectId, "anon-c1", "", "gpt-4o", 9.0, nil, nil, now), + makeConversationTrace(projectId, "", "user-9", "gpt-4o", 9.0, nil, nil, now), + } + if err := AiTraceRepository.InsertAsync(ctx, traces); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + from, to := now.Add(-time.Hour), now.Add(time.Hour) + stats, total, err := AiTraceRepository.FindUserStats(ctx, projectId, from, to, 1, 50, "total_cost", "desc", "") + if err != nil { + t.Fatalf("FindUserStats failed: %v", err) + } + if total != 2 || len(stats) != 2 { + t.Fatalf("expected 2 users, got total=%d rows=%d", total, len(stats)) + } + + byUser := map[string]models.AiUserStats{} + for _, s := range stats { + byUser[s.UserId] = s + } + + u1 := byUser["user-1"] + if u1.ConversationCount != 3 { + t.Errorf("user-1 conversations = %d, expected 3", u1.ConversationCount) + } + if u1.TotalCalls != 8 { + t.Errorf("user-1 calls = %d, expected 8", u1.TotalCalls) + } + if u1.MinTurns != 1 { + t.Errorf("user-1 minTurns = %d, expected 1", u1.MinTurns) + } + assertApproxEqual(t, "user-1 medianTurns", u1.MedianTurns, 2.0, 0.001) + assertApproxEqual(t, "user-1 avgTurns", u1.AvgTurns, 8.0/3.0, 0.001) + assertApproxEqual(t, "user-1 totalCost", u1.TotalCost, 8.0, 0.001) + assertApproxEqual(t, "user-1 avgConversationCost", u1.AvgCostPerConversation, 8.0/3.0, 0.001) + if u1.FlaggedConversationCount != 1 { + t.Errorf("user-1 flaggedConversations = %d, expected 1", u1.FlaggedConversationCount) + } + + u2 := byUser["user-2"] + if u2.ConversationCount != 1 || u2.TotalCalls != 2 { + t.Errorf("user-2 stats wrong: %+v", u2) + } + assertApproxEqual(t, "user-2 medianTurns", u2.MedianTurns, 2.0, 0.001) + assertApproxEqual(t, "user-2 totalCost", u2.TotalCost, 4.0, 0.001) + if u2.FlaggedConversationCount != 0 { + t.Errorf("user-2 flaggedConversations = %d, expected 0", u2.FlaggedConversationCount) + } + + // stats sorted by total_cost desc: user-1 (8.0) before user-2 (4.0). + if stats[0].UserId != "user-1" { + t.Errorf("expected user-1 first by total cost, got %q", stats[0].UserId) + } +} + +func TestAiTraceRepository_GetConversationCosts(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + traces := []models.AiTrace{ + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 1.5, nil, nil, now), + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 2.5, nil, nil, now.Add(time.Minute)), + makeConversationTrace(projectId, "conv-b", "user-2", "gpt-4o", 0.25, nil, nil, now), + // Outside the lookback window. + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 100.0, nil, nil, now.Add(-48*time.Hour)), + } + if err := AiTraceRepository.InsertAsync(ctx, traces); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + costs, err := AiTraceRepository.GetConversationCosts(ctx, projectId, []string{"conv-a", "conv-b", "conv-missing"}, now.Add(-24*time.Hour)) + if err != nil { + t.Fatalf("GetConversationCosts failed: %v", err) + } + assertApproxEqual(t, "conv-a cost", costs["conv-a"], 4.0, 0.001) + assertApproxEqual(t, "conv-b cost", costs["conv-b"], 0.25, 0.001) + if _, ok := costs["conv-missing"]; ok { + t.Error("conv-missing should not be present in the cost map") + } + + empty, err := AiTraceRepository.GetConversationCosts(ctx, projectId, nil, now) + if err != nil { + t.Fatalf("GetConversationCosts with no ids failed: %v", err) + } + if len(empty) != 0 { + t.Errorf("expected empty map, got %v", empty) + } +} + +func TestAiTraceRepository_ListModels(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + now := truncateMs(time.Now().UTC()) + + traces := []models.AiTrace{ + makeConversationTrace(projectId, "conv-a", "user-1", "gpt-4o", 1.0, nil, nil, now), + makeConversationTrace(projectId, "conv-b", "user-1", "claude-sonnet-5", 1.0, nil, nil, now), + makeConversationTrace(projectId, "conv-c", "user-1", "gpt-4o", 1.0, nil, nil, now), + } + if err := AiTraceRepository.InsertAsync(ctx, traces); err != nil { + t.Fatalf("InsertAsync failed: %v", err) + } + + names, err := AiTraceRepository.ListModels(ctx, projectId, now.Add(-time.Hour), now.Add(time.Hour)) + if err != nil { + t.Fatalf("ListModels failed: %v", err) + } + if len(names) != 2 || names[0] != "claude-sonnet-5" || names[1] != "gpt-4o" { + t.Errorf("ListModels = %v, expected [claude-sonnet-5 gpt-4o]", names) + } +} diff --git a/backend/app/repositories/telemetry/clickhouse/ai_trace.repository.go b/backend/app/repositories/telemetry/clickhouse/ai_trace.repository.go index 485d8402..7da22726 100644 --- a/backend/app/repositories/telemetry/clickhouse/ai_trace.repository.go +++ b/backend/app/repositories/telemetry/clickhouse/ai_trace.repository.go @@ -20,7 +20,7 @@ type aiTraceRepository struct{} func (r *aiTraceRepository) InsertAsync(ctx context.Context, lines []models.AiTrace) error { batch, err := chdb.Conn.PrepareBatch(chdb.BatchCtx(), - "INSERT INTO ai_traces (id, project_id, recorded_at, duration, status_code, model, response_model, provider, operation, input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, storage_key, attributes, distributed_trace_id, is_root)") + "INSERT INTO ai_traces (id, project_id, recorded_at, duration, status_code, model, response_model, provider, operation, input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, storage_key, attributes, distributed_trace_id, is_root, conversation_id, tool_call_count, tool_names, flagged, flagged_terms)") if err != nil { return err } @@ -35,6 +35,10 @@ func (r *aiTraceRepository) InsertAsync(ctx context.Context, lines []models.AiTr if t.IsRoot { isRoot = 1 } + flagged := uint8(0) + if t.Flagged { + flagged = 1 + } if err := batch.Append( t.Id, t.ProjectId, t.RecordedAt, int64(t.Duration), t.StatusCode, t.Model, t.ResponseModel, t.Provider, t.Operation, @@ -42,6 +46,7 @@ func (r *aiTraceRepository) InsertAsync(ctx context.Context, lines []models.AiTr t.InputCost, t.OutputCost, t.TotalCost, t.TraceName, t.UserId, t.FinishReason, t.ServerName, t.AppVersion, t.StorageKey, attributesJSON, t.DistributedTraceId, isRoot, + t.ConversationId, t.ToolCallCount, shared.JoinCSV(t.ToolNames), flagged, shared.JoinCSV(t.FlaggedTerms), ); err != nil { return err } @@ -168,7 +173,8 @@ func (r *aiTraceRepository) FindByTraceName(ctx context.Context, projectId uuid. input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE project_id = ? AND trace_name = ? AND recorded_at >= ? AND recorded_at <= ? ORDER BY ` + orderBy + ` ` + sortDir + ` @@ -182,29 +188,45 @@ func (r *aiTraceRepository) FindByTraceName(ctx context.Context, projectId uuid. var traces []models.AiTrace for rows.Next() { - var t models.AiTrace - var attributesJSON string - if err := rows.Scan( - &t.Id, &t.ProjectId, &t.RecordedAt, &t.Duration, &t.StatusCode, - &t.Model, &t.ResponseModel, &t.Provider, &t.Operation, - &t.InputTokens, &t.OutputTokens, &t.TotalTokens, &t.CachedTokens, &t.ReasoningTokens, - &t.InputCost, &t.OutputCost, &t.TotalCost, - &t.TraceName, &t.UserId, &t.FinishReason, &t.ServerName, &t.AppVersion, - &t.StorageKey, &attributesJSON, - ); err != nil { + t, err := scanAiTrace(rows.Scan) + if err != nil { return nil, 0, err } - if attributesJSON != "" && attributesJSON != "{}" { - if err := json.Unmarshal([]byte(attributesJSON), &t.Attributes); err != nil { - t.Attributes = nil - } - } traces = append(traces, t) } return traces, int64(count), nil } +// scanAiTrace scans the canonical full ai_traces column list (see +// aiTraceColumns) into a model, decoding the JSON/CSV/flag columns. +func scanAiTrace(scan func(dest ...any) error) (models.AiTrace, error) { + var t models.AiTrace + var attributesJSON, toolNames, flaggedTerms string + var isRoot, flagged uint8 + if err := scan( + &t.Id, &t.ProjectId, &t.RecordedAt, &t.Duration, &t.StatusCode, + &t.Model, &t.ResponseModel, &t.Provider, &t.Operation, + &t.InputTokens, &t.OutputTokens, &t.TotalTokens, &t.CachedTokens, &t.ReasoningTokens, + &t.InputCost, &t.OutputCost, &t.TotalCost, + &t.TraceName, &t.UserId, &t.FinishReason, &t.ServerName, &t.AppVersion, + &t.StorageKey, &attributesJSON, &t.DistributedTraceId, &isRoot, + &t.ConversationId, &t.ToolCallCount, &toolNames, &flagged, &flaggedTerms, + ); err != nil { + return t, err + } + t.IsRoot = isRoot == 1 + t.Flagged = flagged == 1 + t.ToolNames = shared.SplitCSV(toolNames) + t.FlaggedTerms = shared.SplitCSV(flaggedTerms) + if attributesJSON != "" && attributesJSON != "{}" { + if err := json.Unmarshal([]byte(attributesJSON), &t.Attributes); err != nil { + t.Attributes = nil + } + } + return t, nil +} + func (r *aiTraceRepository) GetTraceNameStats(ctx context.Context, projectId uuid.UUID, traceName string, start, end time.Time) (*models.AiTraceDetailStats, error) { durationMinutes := end.Sub(start).Minutes() if durationMinutes < 1 { @@ -252,7 +274,8 @@ func (r *aiTraceRepository) FindById(ctx context.Context, projectId, traceId uui input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes, distributed_trace_id, is_root + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE project_id = ? AND id = ?` args := []any{projectId, traceId} @@ -263,31 +286,14 @@ func (r *aiTraceRepository) FindById(ctx context.Context, projectId, traceId uui } query += ` LIMIT 1` - var t models.AiTrace - var attributesJSON string - var isRoot uint8 - - err := chdb.Conn.QueryRow(ctx, query, args...).Scan( - &t.Id, &t.ProjectId, &t.RecordedAt, &t.Duration, &t.StatusCode, - &t.Model, &t.ResponseModel, &t.Provider, &t.Operation, - &t.InputTokens, &t.OutputTokens, &t.TotalTokens, &t.CachedTokens, &t.ReasoningTokens, - &t.InputCost, &t.OutputCost, &t.TotalCost, - &t.TraceName, &t.UserId, &t.FinishReason, &t.ServerName, &t.AppVersion, - &t.StorageKey, &attributesJSON, &t.DistributedTraceId, &isRoot, - ) + row := chdb.Conn.QueryRow(ctx, query, args...) + t, err := scanAiTrace(row.Scan) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } - t.IsRoot = isRoot == 1 - - if attributesJSON != "" && attributesJSON != "{}" { - if err := json.Unmarshal([]byte(attributesJSON), &t.Attributes); err != nil { - t.Attributes = nil - } - } return &t, nil } @@ -308,7 +314,8 @@ func (r *aiTraceRepository) FindByDistributedTraceId(ctx context.Context, distri input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes, distributed_trace_id, is_root + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE distributed_trace_id = ? AND project_id IN (` + strings.Join(placeholders, ",") + `)` if recordedAt != nil { @@ -326,28 +333,344 @@ func (r *aiTraceRepository) FindByDistributedTraceId(ctx context.Context, distri var traces []models.AiTrace for rows.Next() { - var t models.AiTrace - var attributesJSON string - var isRoot uint8 + t, err := scanAiTrace(rows.Scan) + if err != nil { + return nil, err + } + traces = append(traces, t) + } + return traces, nil +} + +func (r *aiTraceRepository) FindConversations(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time, page, pageSize int, orderBy, sortDirection, search, userId, model, toolName string, flaggedOnly bool) ([]models.AiConversationStats, int64, *models.AiConversationThresholds, error) { + // WHERE columns are table-qualified: the select list aliases aggregates to + // the same names (max(user_id) AS user_id), and ClickHouse resolves bare + // names in WHERE against those aliases. + whereClause := "ai_traces.project_id = ? AND ai_traces.recorded_at >= ? AND ai_traces.recorded_at <= ? AND ai_traces.conversation_id != ''" + args := []interface{}{projectId, fromDate, toDate} + + // Row-level filters use a semi-join on conversation_id: a conversation + // matches when ANY of its turns matches, and its aggregates still cover + // all turns (a plain WHERE would drop the non-matching turns from the + // sums). The subquery has its own scope, so bare column names are safe + // there. + var rowPredicates []string + var rowArgs []interface{} + if search != "" { + rowPredicates = append(rowPredicates, + "(positionCaseInsensitive(conversation_id, ?) > 0 OR positionCaseInsensitive(user_id, ?) > 0 OR positionCaseInsensitive(model, ?) > 0 OR positionCaseInsensitive(tool_names, ?) > 0 OR positionCaseInsensitive(flagged_terms, ?) > 0)") + rowArgs = append(rowArgs, search, search, search, search, search) + } + if userId != "" { + rowPredicates = append(rowPredicates, "user_id = ?") + rowArgs = append(rowArgs, userId) + } + if model != "" { + rowPredicates = append(rowPredicates, "model = ?") + rowArgs = append(rowArgs, model) + } + if toolName != "" { + rowPredicates = append(rowPredicates, "position(concat(',', tool_names, ','), concat(',', ?, ',')) > 0") + rowArgs = append(rowArgs, toolName) + } + if len(rowPredicates) > 0 { + whereClause += " AND ai_traces.conversation_id IN (SELECT DISTINCT conversation_id FROM ai_traces WHERE project_id = ? AND recorded_at >= ? AND recorded_at <= ? AND conversation_id != '' AND " + + strings.Join(rowPredicates, " AND ") + ")" + args = append(args, projectId, fromDate, toDate) + args = append(args, rowArgs...) + } + + havingClause := "" + if flaggedOnly { + // References the max(flagged) select alias: ClickHouse rejects + // repeating the aggregate inside HAVING as nested aggregation. + havingClause = " HAVING flagged = 1" + } + + groupedQuery := `SELECT + conversation_id, + max(user_id) AS user_id, + count() AS turns, + sum(total_tokens) AS total_tokens, + sum(total_cost) AS total_cost, + sum(tool_call_count) AS tool_call_count, + arrayStringConcat(groupUniqArray(tool_names), ',') AS tool_names, + arrayStringConcat(groupUniqArray(model), ',') AS models, + max(flagged) AS flagged, + arrayStringConcat(groupUniqArray(flagged_terms), ',') AS flagged_terms, + min(recorded_at) AS first_seen, + max(recorded_at) AS last_seen + FROM ai_traces + WHERE ` + whereClause + ` + GROUP BY conversation_id` + havingClause + + var count uint64 + if err := chdb.Conn.QueryRow(ctx, + "SELECT count() FROM ("+groupedQuery+")", args...).Scan(&count); err != nil { + return nil, 0, nil, err + } + + thresholds := &models.AiConversationThresholds{} + if err := chdb.Conn.QueryRow(ctx, + "SELECT quantile(0.95)(total_cost), quantile(0.95)(turns) FROM ("+groupedQuery+")", + args...).Scan(&thresholds.P95Cost, &thresholds.P95Turns); err != nil { + return nil, 0, nil, err + } + + orderByMap := map[string]string{ + "turns": "turns", + "total_cost": "total_cost", + "total_tokens": "total_tokens", + "tool_call_count": "tool_call_count", + "user_id": "user_id", + "first_seen": "first_seen", + "last_seen": "last_seen", + } + orderExpr, ok := orderByMap[orderBy] + if !ok { + orderExpr = "last_seen" + } + sortDir := "DESC" + if sortDirection == "asc" { + sortDir = "ASC" + } + offset := (page - 1) * pageSize + + rows, err := chdb.Conn.Query(ctx, + groupedQuery+` ORDER BY `+orderExpr+` `+sortDir+` LIMIT ? OFFSET ?`, + append(append([]interface{}{}, args...), pageSize, offset)...) + if err != nil { + return nil, 0, nil, err + } + defer rows.Close() + + var stats []models.AiConversationStats + for rows.Next() { + var s models.AiConversationStats + var turns uint64 + var toolNames, modelsCSV, flaggedTerms string + var flagged uint8 if err := rows.Scan( - &t.Id, &t.ProjectId, &t.RecordedAt, &t.Duration, &t.StatusCode, - &t.Model, &t.ResponseModel, &t.Provider, &t.Operation, - &t.InputTokens, &t.OutputTokens, &t.TotalTokens, &t.CachedTokens, &t.ReasoningTokens, - &t.InputCost, &t.OutputCost, &t.TotalCost, - &t.TraceName, &t.UserId, &t.FinishReason, &t.ServerName, &t.AppVersion, - &t.StorageKey, &attributesJSON, &t.DistributedTraceId, &isRoot, + &s.ConversationId, &s.UserId, &turns, + &s.TotalTokens, &s.TotalCost, &s.ToolCallCount, + &toolNames, &modelsCSV, &flagged, &flaggedTerms, + &s.FirstSeen, &s.LastSeen, ); err != nil { - return nil, err + return nil, 0, nil, err } - t.IsRoot = isRoot == 1 - if attributesJSON != "" && attributesJSON != "{}" { - if err := json.Unmarshal([]byte(attributesJSON), &t.Attributes); err != nil { - t.Attributes = nil - } + s.Turns = int64(turns) + s.ToolNames = shared.UnionCSV(toolNames) + s.Models = shared.UnionCSV(modelsCSV) + s.Flagged = flagged == 1 + s.FlaggedTerms = shared.UnionCSV(flaggedTerms) + stats = append(stats, s) + } + + return stats, int64(count), thresholds, nil +} + +func (r *aiTraceRepository) FindByConversationId(ctx context.Context, projectId uuid.UUID, conversationId string, fromDate, toDate time.Time) ([]models.AiTrace, *models.AiConversationDetailStats, error) { + query := `SELECT id, project_id, recorded_at, duration, status_code, + model, response_model, provider, operation, + input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, + input_cost, output_cost, total_cost, + trace_name, user_id, finish_reason, server_name, app_version, + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms + FROM ai_traces + WHERE project_id = ? AND conversation_id = ?` + args := []interface{}{projectId, conversationId} + if !fromDate.IsZero() { + query += ` AND recorded_at >= ?` + args = append(args, fromDate) + } + if !toDate.IsZero() { + query += ` AND recorded_at <= ?` + args = append(args, toDate) + } + query += ` ORDER BY recorded_at ASC LIMIT 1000` + + rows, err := chdb.Conn.Query(ctx, query, args...) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + var traces []models.AiTrace + for rows.Next() { + t, err := scanAiTrace(rows.Scan) + if err != nil { + return nil, nil, err } traces = append(traces, t) } - return traces, nil + return traces, shared.BuildConversationDetailStats(traces), nil +} + +func (r *aiTraceRepository) FindUserStats(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time, page, pageSize int, orderBy, sortDirection, search string) ([]models.AiUserStats, int64, error) { + whereClause := "project_id = ? AND recorded_at >= ? AND recorded_at <= ? AND user_id != '' AND conversation_id != ''" + args := []interface{}{projectId, fromDate, toDate} + if search != "" { + whereClause += " AND positionCaseInsensitive(user_id, ?) > 0" + args = append(args, search) + } + + var count uint64 + if err := chdb.Conn.QueryRow(ctx, + "SELECT uniq(user_id) FROM ai_traces WHERE "+whereClause, args...).Scan(&count); err != nil { + return nil, 0, err + } + + orderByMap := map[string]string{ + "conversation_count": "conversation_count", + "total_calls": "total_calls", + "avg_turns": "avg_turns", + "min_turns": "min_turns", + "median_turns": "median_turns", + "avg_conversation_cost": "avg_conversation_cost", + "total_cost": "total_cost", + "flagged_conversation_count": "flagged_conversation_count", + "total_tokens": "total_tokens", + "last_seen": "last_seen", + } + orderExpr, ok := orderByMap[orderBy] + if !ok { + orderExpr = "total_cost" + } + sortDir := "DESC" + if sortDirection == "asc" { + sortDir = "ASC" + } + offset := (page - 1) * pageSize + + query := `SELECT + user_id, + count() AS conversation_count, + sum(turns) AS total_calls, + avg(turns) AS avg_turns, + min(turns) AS min_turns, + quantile(0.5)(turns) AS median_turns, + avg(conv_cost) AS avg_conversation_cost, + sum(conv_cost) AS total_cost, + sum(conv_flagged) AS flagged_conversation_count, + sum(conv_tokens) AS total_tokens, + max(conv_last_seen) AS last_seen + FROM ( + SELECT user_id, conversation_id, + count() AS turns, + sum(total_cost) AS conv_cost, + sum(total_tokens) AS conv_tokens, + max(flagged) AS conv_flagged, + max(recorded_at) AS conv_last_seen + FROM ai_traces + WHERE ` + whereClause + ` + GROUP BY user_id, conversation_id + ) + GROUP BY user_id + ORDER BY ` + orderExpr + ` ` + sortDir + ` + LIMIT ? OFFSET ?` + + rows, err := chdb.Conn.Query(ctx, query, append(append([]interface{}{}, args...), pageSize, offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var stats []models.AiUserStats + for rows.Next() { + var s models.AiUserStats + var conversationCount, totalCalls, minTurns, flaggedCount uint64 + if err := rows.Scan( + &s.UserId, &conversationCount, &totalCalls, + &s.AvgTurns, &minTurns, &s.MedianTurns, + &s.AvgCostPerConversation, &s.TotalCost, + &flaggedCount, &s.TotalTokens, &s.LastSeen, + ); err != nil { + return nil, 0, err + } + s.ConversationCount = int64(conversationCount) + s.TotalCalls = int64(totalCalls) + s.MinTurns = int64(minTurns) + s.FlaggedConversationCount = int64(flaggedCount) + stats = append(stats, s) + } + + return stats, int64(count), nil +} + +func (r *aiTraceRepository) GetConversationCosts(ctx context.Context, projectId uuid.UUID, conversationIds []string, since time.Time) (map[string]float64, error) { + costs := make(map[string]float64, len(conversationIds)) + if len(conversationIds) == 0 { + return costs, nil + } + placeholders := make([]string, len(conversationIds)) + args := []interface{}{projectId, since} + for i, id := range conversationIds { + placeholders[i] = "?" + args = append(args, id) + } + + rows, err := chdb.Conn.Query(ctx, + `SELECT conversation_id, sum(total_cost) + FROM ai_traces + WHERE project_id = ? AND recorded_at >= ? AND conversation_id IN (`+strings.Join(placeholders, ",")+`) + GROUP BY conversation_id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var conversationId string + var totalCost float64 + if err := rows.Scan(&conversationId, &totalCost); err != nil { + return nil, err + } + costs[conversationId] = totalCost + } + return costs, nil +} + +func (r *aiTraceRepository) ListModels(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time) ([]string, error) { + rows, err := chdb.Conn.Query(ctx, + `SELECT DISTINCT model FROM ai_traces + WHERE project_id = ? AND recorded_at >= ? AND recorded_at <= ? AND model != '' + ORDER BY model LIMIT 200`, projectId, fromDate, toDate) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, nil +} + +func (r *aiTraceRepository) ListToolNames(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time) ([]string, error) { + rows, err := chdb.Conn.Query(ctx, + `SELECT DISTINCT tool_names FROM ai_traces + WHERE project_id = ? AND recorded_at >= ? AND recorded_at <= ? AND tool_names != '' + LIMIT 500`, projectId, fromDate, toDate) + if err != nil { + return nil, err + } + defer rows.Close() + + var values []string + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return nil, err + } + values = append(values, value) + } + return shared.UnionCSV(shared.JoinCSV(values)), nil } var AiTraceRepository = &aiTraceRepository{} diff --git a/backend/app/repositories/telemetry/duckdb/ai_trace.repository.go b/backend/app/repositories/telemetry/duckdb/ai_trace.repository.go index f2c8fe1a..0c62c31a 100644 --- a/backend/app/repositories/telemetry/duckdb/ai_trace.repository.go +++ b/backend/app/repositories/telemetry/duckdb/ai_trace.repository.go @@ -51,6 +51,11 @@ type aiTraceRow struct { Attributes sqlitetypes.SQLiteJSONMap `lit:"attributes"` DistributedTraceId *uuid.UUID `lit:"distributed_trace_id"` IsRoot bool `lit:"is_root"` + ConversationId string `lit:"conversation_id"` + ToolCallCount int64 `lit:"tool_call_count"` + ToolNames string `lit:"tool_names"` + Flagged bool `lit:"flagged"` + FlaggedTerms string `lit:"flagged_terms"` } type groupedAiTraceRow struct { @@ -79,11 +84,53 @@ type aiTraceDetailStatsRow struct { AvgOutputTokens float64 `lit:"avg_output_tokens"` } +type conversationStatsRow struct { + ConversationId string `lit:"conversation_id"` + UserId string `lit:"user_id"` + Turns int64 `lit:"turns"` + TotalTokens int64 `lit:"total_tokens"` + TotalCost float64 `lit:"total_cost"` + ToolCallCount int64 `lit:"tool_call_count"` + ToolNames string `lit:"tool_names"` + Models string `lit:"models"` + Flagged bool `lit:"flagged"` + FlaggedTerms string `lit:"flagged_terms"` + FirstSeen time.Time `lit:"first_seen"` + LastSeen time.Time `lit:"last_seen"` +} + +type userConversationRow struct { + UserId string `lit:"user_id"` + Turns int64 `lit:"turns"` + ConvCost float64 `lit:"conv_cost"` + ConvTokens int64 `lit:"conv_tokens"` + ConvFlagged bool `lit:"conv_flagged"` + LastSeen time.Time `lit:"last_seen"` +} + +type conversationCostRow struct { + ConversationId string `lit:"conversation_id"` + TotalCost float64 `lit:"total_cost"` +} + +type modelNameRow struct { + Model string `lit:"model"` +} + +type toolNamesRow struct { + ToolNames string `lit:"tool_names"` +} + func init() { models.ExtensionModelRegistrations = append(models.ExtensionModelRegistrations, func(driver lit.Driver) { lit.RegisterModelWithNaming[aiTraceRow](driver, aiTraceRowNaming{}) lit.RegisterModel[groupedAiTraceRow](driver) lit.RegisterModel[aiTraceDetailStatsRow](driver) + lit.RegisterModel[conversationStatsRow](driver) + lit.RegisterModel[userConversationRow](driver) + lit.RegisterModel[conversationCostRow](driver) + lit.RegisterModel[modelNameRow](driver) + lit.RegisterModel[toolNamesRow](driver) }) } @@ -114,6 +161,11 @@ func (r *aiTraceRow) toModel() models.AiTrace { StorageKey: r.StorageKey, DistributedTraceId: r.DistributedTraceId, IsRoot: r.IsRoot, + ConversationId: r.ConversationId, + ToolCallCount: r.ToolCallCount, + ToolNames: shared.SplitCSV(r.ToolNames), + Flagged: r.Flagged, + FlaggedTerms: shared.SplitCSV(r.FlaggedTerms), } if r.Attributes != nil { t.Attributes = map[string]string(r.Attributes) @@ -145,7 +197,10 @@ func (r *aiTraceRepository) InsertAsync(ctx context.Context, lines []models.AiTr isRoot := boolToInt(t.IsRoot) - // Column order follows the ai_traces DDL exactly: is_root precedes distributed_trace_id. + // Column order follows the ai_traces DDL exactly: is_root precedes + // distributed_trace_id, and the 0002 migration's conversation columns + // (conversation_id, tool_call_count, tool_names, flagged, flagged_terms) + // come last in that order. if err := appender.AppendRow( t.Id.String(), t.ProjectId.String(), @@ -173,6 +228,11 @@ func (r *aiTraceRepository) InsertAsync(ctx context.Context, lines []models.AiTr attributesJSON, isRoot, nullableString(distributedTraceId), + t.ConversationId, + t.ToolCallCount, + shared.JoinCSV(t.ToolNames), + boolToInt(t.Flagged), + shared.JoinCSV(t.FlaggedTerms), ); err != nil { captureDroppedRow("ai_traces", err) } @@ -305,7 +365,8 @@ func (r *aiTraceRepository) FindByTraceName(ctx context.Context, projectId uuid. input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes, distributed_trace_id, is_root + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE project_id = :project_id AND trace_name = :trace_name AND recorded_at >= :from AND recorded_at <= :to ORDER BY %s %s LIMIT :limit OFFSET :offset`, orderBy, sortDir), @@ -368,7 +429,8 @@ func (r *aiTraceRepository) FindById(ctx context.Context, projectId, traceId uui input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes, distributed_trace_id, is_root + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE project_id = :project_id AND id = :id` params := lit.P{"project_id": projectId, "id": traceId} @@ -407,7 +469,8 @@ func (r *aiTraceRepository) FindByDistributedTraceId(ctx context.Context, distri input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes, distributed_trace_id, is_root + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE distributed_trace_id = :trace_id AND project_id IN (` + strings.Join(placeholders, ",") + `)` if recordedAt != nil { from, to := shared.DistributedTraceWindowBounds(*recordedAt) @@ -437,6 +500,7 @@ func (r *aiTraceRepository) FindByDistributedTraceId(ctx context.Context, distri &row.InputCost, &row.OutputCost, &row.TotalCost, &row.TraceName, &row.UserId, &row.FinishReason, &row.ServerName, &row.AppVersion, &row.StorageKey, &row.Attributes, &row.DistributedTraceId, &row.IsRoot, + &row.ConversationId, &row.ToolCallCount, &row.ToolNames, &row.Flagged, &row.FlaggedTerms, ); err != nil { return nil, err } @@ -445,4 +509,217 @@ func (r *aiTraceRepository) FindByDistributedTraceId(ctx context.Context, distri return traces, nil } +func (r *aiTraceRepository) FindConversations(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time, page, pageSize int, orderBy, sortDirection, search, userId, model, toolName string, flaggedOnly bool) ([]models.AiConversationStats, int64, *models.AiConversationThresholds, error) { + params := lit.P{"project_id": projectId, "from": fromDate.UTC(), "to": toDate.UTC()} + + baseWhere := "project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND conversation_id != ''" + + // Row-level filters use a semi-join on conversation_id: a conversation + // matches when ANY of its turns matches, and its aggregates still cover + // all turns (a plain WHERE would drop the non-matching turns from the + // sums). + var rowPredicates []string + if search != "" { + rowPredicates = append(rowPredicates, + "(INSTR(LOWER(conversation_id), LOWER(:search)) > 0 OR INSTR(LOWER(user_id), LOWER(:search)) > 0 OR INSTR(LOWER(model), LOWER(:search)) > 0 OR INSTR(LOWER(tool_names), LOWER(:search)) > 0 OR INSTR(LOWER(flagged_terms), LOWER(:search)) > 0)") + params["search"] = search + } + if userId != "" { + rowPredicates = append(rowPredicates, "user_id = :user_id") + params["user_id"] = userId + } + if model != "" { + rowPredicates = append(rowPredicates, "model = :model") + params["model"] = model + } + if toolName != "" { + rowPredicates = append(rowPredicates, "INSTR(',' || tool_names || ',', ',' || :tool_name || ',') > 0") + params["tool_name"] = toolName + } + + whereClause := baseWhere + if len(rowPredicates) > 0 { + whereClause += " AND conversation_id IN (SELECT DISTINCT conversation_id FROM ai_traces WHERE " + + baseWhere + " AND " + strings.Join(rowPredicates, " AND ") + ")" + } + + havingClause := "" + if flaggedOnly { + havingClause = " HAVING MAX(flagged) = 1" + } + + rows, err := lit.SelectNamed[conversationStatsRow](db.TelemetryDB, + `SELECT conversation_id, + MAX(user_id) AS user_id, + COUNT(*) AS turns, + CAST(SUM(total_tokens) AS BIGINT) AS total_tokens, + SUM(total_cost) AS total_cost, + CAST(SUM(tool_call_count) AS BIGINT) AS tool_call_count, + string_agg(DISTINCT tool_names, ',') AS tool_names, + string_agg(DISTINCT model, ',') AS models, + MAX(flagged) AS flagged, + string_agg(DISTINCT flagged_terms, ',') AS flagged_terms, + MIN(recorded_at) AS first_seen, + MAX(recorded_at) AS last_seen + FROM ai_traces WHERE `+whereClause+` + GROUP BY conversation_id`+havingClause, params) + if err != nil { + return nil, 0, nil, err + } + + stats := make([]models.AiConversationStats, 0, len(rows)) + for _, row := range rows { + stats = append(stats, models.AiConversationStats{ + ConversationId: row.ConversationId, + UserId: row.UserId, + Turns: row.Turns, + TotalTokens: row.TotalTokens, + TotalCost: row.TotalCost, + ToolCallCount: row.ToolCallCount, + ToolNames: shared.UnionCSV(row.ToolNames), + Models: shared.UnionCSV(row.Models), + Flagged: row.Flagged, + FlaggedTerms: shared.UnionCSV(row.FlaggedTerms), + FirstSeen: row.FirstSeen, + LastSeen: row.LastSeen, + }) + } + + total := int64(len(stats)) + thresholds := shared.ConversationThresholds(stats) + stats = shared.SortAndPageConversations(stats, orderBy, sortDirection, page, pageSize) + return stats, total, thresholds, nil +} + +func (r *aiTraceRepository) FindByConversationId(ctx context.Context, projectId uuid.UUID, conversationId string, fromDate, toDate time.Time) ([]models.AiTrace, *models.AiConversationDetailStats, error) { + query := `SELECT id, project_id, recorded_at, duration, status_code, + model, response_model, provider, operation, + input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, + input_cost, output_cost, total_cost, + trace_name, user_id, finish_reason, server_name, app_version, + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms + FROM ai_traces + WHERE project_id = :project_id AND conversation_id = :conversation_id` + params := lit.P{"project_id": projectId, "conversation_id": conversationId} + if !fromDate.IsZero() { + query += ` AND recorded_at >= :from` + params["from"] = fromDate.UTC() + } + if !toDate.IsZero() { + query += ` AND recorded_at <= :to` + params["to"] = toDate.UTC() + } + query += ` ORDER BY recorded_at ASC LIMIT 1000` + + rows, err := lit.SelectNamed[aiTraceRow](db.TelemetryDB, query, params) + if err != nil { + return nil, nil, err + } + + traces := make([]models.AiTrace, 0, len(rows)) + for _, row := range rows { + traces = append(traces, row.toModel()) + } + return traces, shared.BuildConversationDetailStats(traces), nil +} + +func (r *aiTraceRepository) FindUserStats(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time, page, pageSize int, orderBy, sortDirection, search string) ([]models.AiUserStats, int64, error) { + params := lit.P{"project_id": projectId, "from": fromDate.UTC(), "to": toDate.UTC()} + + whereClause := "project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND user_id != '' AND conversation_id != ''" + if search != "" { + whereClause += " AND INSTR(LOWER(user_id), LOWER(:search)) > 0" + params["search"] = search + } + + rows, err := lit.SelectNamed[userConversationRow](db.TelemetryDB, + `SELECT user_id, + COUNT(*) AS turns, + SUM(total_cost) AS conv_cost, + CAST(SUM(total_tokens) AS BIGINT) AS conv_tokens, + MAX(flagged) AS conv_flagged, + MAX(recorded_at) AS last_seen + FROM ai_traces WHERE `+whereClause+` + GROUP BY user_id, conversation_id`, params) + if err != nil { + return nil, 0, err + } + + aggRows := make([]shared.UserConversationAgg, 0, len(rows)) + for _, row := range rows { + aggRows = append(aggRows, shared.UserConversationAgg{ + UserId: row.UserId, + Turns: row.Turns, + Cost: row.ConvCost, + Tokens: row.ConvTokens, + Flagged: row.ConvFlagged, + LastSeen: row.LastSeen, + }) + } + + stats := shared.AggregateUserStats(aggRows) + total := int64(len(stats)) + stats = shared.SortAndPageUserStats(stats, orderBy, sortDirection, page, pageSize) + return stats, total, nil +} + +func (r *aiTraceRepository) GetConversationCosts(ctx context.Context, projectId uuid.UUID, conversationIds []string, since time.Time) (map[string]float64, error) { + costs := make(map[string]float64, len(conversationIds)) + if len(conversationIds) == 0 { + return costs, nil + } + params := lit.P{"project_id": projectId, "since": since.UTC()} + placeholders := make([]string, len(conversationIds)) + for i, id := range conversationIds { + key := fmt.Sprintf("cid_%d", i) + placeholders[i] = ":" + key + params[key] = id + } + rows, err := lit.SelectNamed[conversationCostRow](db.TelemetryDB, + `SELECT conversation_id, SUM(total_cost) AS total_cost + FROM ai_traces + WHERE project_id = :project_id AND recorded_at >= :since AND conversation_id IN (`+strings.Join(placeholders, ",")+`) + GROUP BY conversation_id`, params) + if err != nil { + return nil, err + } + for _, row := range rows { + costs[row.ConversationId] = row.TotalCost + } + return costs, nil +} + +func (r *aiTraceRepository) ListModels(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time) ([]string, error) { + rows, err := lit.SelectNamed[modelNameRow](db.TelemetryDB, + `SELECT DISTINCT model FROM ai_traces + WHERE project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND model != '' + ORDER BY model LIMIT 200`, + lit.P{"project_id": projectId, "from": fromDate.UTC(), "to": toDate.UTC()}) + if err != nil { + return nil, err + } + names := make([]string, 0, len(rows)) + for _, row := range rows { + names = append(names, row.Model) + } + return names, nil +} + +func (r *aiTraceRepository) ListToolNames(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time) ([]string, error) { + rows, err := lit.SelectNamed[toolNamesRow](db.TelemetryDB, + `SELECT DISTINCT tool_names FROM ai_traces + WHERE project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND tool_names != '' + LIMIT 500`, + lit.P{"project_id": projectId, "from": fromDate.UTC(), "to": toDate.UTC()}) + if err != nil { + return nil, err + } + values := make([]string, 0, len(rows)) + for _, row := range rows { + values = append(values, row.ToolNames) + } + return shared.UnionCSV(shared.JoinCSV(values)), nil +} + var AiTraceRepository = &aiTraceRepository{} diff --git a/backend/app/repositories/telemetry/shared/ai_conversations.go b/backend/app/repositories/telemetry/shared/ai_conversations.go new file mode 100644 index 00000000..1e33660a --- /dev/null +++ b/backend/app/repositories/telemetry/shared/ai_conversations.go @@ -0,0 +1,268 @@ +package shared + +import ( + "sort" + "strings" + "time" + + "github.com/tracewayapp/traceway/backend/app/models" +) + +// JoinCSV renders a string slice as the comma-separated form the ai_traces +// tool_names / flagged_terms columns use. Values are sanitized at ingest so +// they never contain commas. +func JoinCSV(values []string) string { + return strings.Join(values, ",") +} + +// SplitCSV splits a comma-separated column value, dropping empty entries. +func SplitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// UnionCSV splits a string-agg of comma-separated column values (each element +// itself possibly a comma-separated list) into a sorted, deduplicated slice. +func UnionCSV(concatenated string) []string { + parts := SplitCSV(concatenated) + if len(parts) == 0 { + return nil + } + seen := make(map[string]struct{}, len(parts)) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if _, dup := seen[p]; dup { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + sort.Strings(out) + return out +} + +// ConversationThresholds computes the range-wide P95 outlier cutoffs over the +// full (pre-pagination) conversation group list. Used by the embedded +// backends; ClickHouse computes the same values in SQL. +func ConversationThresholds(stats []models.AiConversationStats) *models.AiConversationThresholds { + if len(stats) == 0 { + return &models.AiConversationThresholds{} + } + costs := make([]float64, len(stats)) + turns := make([]float64, len(stats)) + for i, s := range stats { + costs[i] = s.TotalCost + turns[i] = float64(s.Turns) + } + sort.Float64s(costs) + sort.Float64s(turns) + return &models.AiConversationThresholds{ + P95Cost: ComputePercentile(costs, 0.95), + P95Turns: ComputePercentile(turns, 0.95), + } +} + +// SortAndPageConversations orders conversation stats by a whitelisted column +// and clips to the requested page. Shared by the embedded backends, which +// fetch all groups and sort in Go. +func SortAndPageConversations(stats []models.AiConversationStats, orderBy, sortDirection string, page, pageSize int) []models.AiConversationStats { + orderByMap := map[string]func(i, j int) bool{ + "turns": func(i, j int) bool { return stats[i].Turns > stats[j].Turns }, + "total_cost": func(i, j int) bool { return stats[i].TotalCost > stats[j].TotalCost }, + "total_tokens": func(i, j int) bool { return stats[i].TotalTokens > stats[j].TotalTokens }, + "tool_call_count": func(i, j int) bool { return stats[i].ToolCallCount > stats[j].ToolCallCount }, + "user_id": func(i, j int) bool { return stats[i].UserId > stats[j].UserId }, + "first_seen": func(i, j int) bool { return stats[i].FirstSeen.After(stats[j].FirstSeen) }, + "last_seen": func(i, j int) bool { return stats[i].LastSeen.After(stats[j].LastSeen) }, + } + sortFn, ok := orderByMap[orderBy] + if !ok { + sortFn = orderByMap["last_seen"] + } + if sortDirection == "asc" { + origFn := sortFn + sortFn = func(i, j int) bool { return !origFn(i, j) } + } + sort.SliceStable(stats, sortFn) + return PageSlice(stats, page, pageSize) +} + +// UserConversationAgg is one (user, conversation) group produced by the inner +// aggregation query; AggregateUserStats rolls these up per user. +type UserConversationAgg struct { + UserId string + Turns int64 + Cost float64 + Tokens int64 + Flagged bool + LastSeen time.Time +} + +// AggregateUserStats computes per-user conversation statistics (count, +// avg/min/median turns, cost aggregates) from per-conversation rows. Used by +// the embedded backends, which have no SQL median; ClickHouse and DuckDB +// could compute this in SQL but share the Go path via their inner queries to +// keep the three backends' semantics identical. +func AggregateUserStats(rows []UserConversationAgg) []models.AiUserStats { + type userAcc struct { + turns []float64 + calls int64 + cost float64 + tokens int64 + flagged int64 + minTurns int64 + lastSeen time.Time + } + byUser := map[string]*userAcc{} + order := []string{} + for _, row := range rows { + acc, ok := byUser[row.UserId] + if !ok { + acc = &userAcc{minTurns: row.Turns} + byUser[row.UserId] = acc + order = append(order, row.UserId) + } + acc.turns = append(acc.turns, float64(row.Turns)) + acc.calls += row.Turns + acc.cost += row.Cost + acc.tokens += row.Tokens + if row.Flagged { + acc.flagged++ + } + if row.Turns < acc.minTurns { + acc.minTurns = row.Turns + } + if row.LastSeen.After(acc.lastSeen) { + acc.lastSeen = row.LastSeen + } + } + + stats := make([]models.AiUserStats, 0, len(order)) + for _, userId := range order { + acc := byUser[userId] + sort.Float64s(acc.turns) + conversations := int64(len(acc.turns)) + var avgTurns, avgCost float64 + if conversations > 0 { + avgTurns = float64(acc.calls) / float64(conversations) + avgCost = acc.cost / float64(conversations) + } + stats = append(stats, models.AiUserStats{ + UserId: userId, + ConversationCount: conversations, + TotalCalls: acc.calls, + AvgTurns: avgTurns, + MinTurns: acc.minTurns, + MedianTurns: ComputePercentile(acc.turns, 0.5), + AvgCostPerConversation: avgCost, + TotalCost: acc.cost, + FlaggedConversationCount: acc.flagged, + TotalTokens: acc.tokens, + LastSeen: acc.lastSeen, + }) + } + return stats +} + +// SortAndPageUserStats orders user stats by a whitelisted column and clips to +// the requested page. +func SortAndPageUserStats(stats []models.AiUserStats, orderBy, sortDirection string, page, pageSize int) []models.AiUserStats { + orderByMap := map[string]func(i, j int) bool{ + "conversation_count": func(i, j int) bool { return stats[i].ConversationCount > stats[j].ConversationCount }, + "total_calls": func(i, j int) bool { return stats[i].TotalCalls > stats[j].TotalCalls }, + "avg_turns": func(i, j int) bool { return stats[i].AvgTurns > stats[j].AvgTurns }, + "min_turns": func(i, j int) bool { return stats[i].MinTurns > stats[j].MinTurns }, + "median_turns": func(i, j int) bool { return stats[i].MedianTurns > stats[j].MedianTurns }, + "avg_conversation_cost": func(i, j int) bool { return stats[i].AvgCostPerConversation > stats[j].AvgCostPerConversation }, + "total_cost": func(i, j int) bool { return stats[i].TotalCost > stats[j].TotalCost }, + "flagged_conversation_count": func(i, j int) bool { return stats[i].FlaggedConversationCount > stats[j].FlaggedConversationCount }, + "total_tokens": func(i, j int) bool { return stats[i].TotalTokens > stats[j].TotalTokens }, + "last_seen": func(i, j int) bool { return stats[i].LastSeen.After(stats[j].LastSeen) }, + } + sortFn, ok := orderByMap[orderBy] + if !ok { + sortFn = orderByMap["total_cost"] + } + if sortDirection == "asc" { + origFn := sortFn + sortFn = func(i, j int) bool { return !origFn(i, j) } + } + sort.SliceStable(stats, sortFn) + return PageSlice(stats, page, pageSize) +} + +// PageSlice clips a slice to one page, mirroring the offset/limit clipping +// the embedded backends do inline elsewhere. +func PageSlice[T any](items []T, page, pageSize int) []T { + offset := (page - 1) * pageSize + end := offset + pageSize + if offset > len(items) { + return nil + } + if end > len(items) { + return items[offset:] + } + return items[offset:end] +} + +// BuildConversationDetailStats derives conversation-level stats from the +// already-fetched turns, identically on every backend. +func BuildConversationDetailStats(turns []models.AiTrace) *models.AiConversationDetailStats { + stats := &models.AiConversationDetailStats{} + if len(turns) == 0 { + return stats + } + + var totalDuration time.Duration + modelSet := map[string]struct{}{} + termSet := map[string]struct{}{} + stats.FirstSeen = turns[0].RecordedAt + stats.LastSeen = turns[0].RecordedAt + for _, t := range turns { + stats.Turns++ + stats.TotalTokens += t.TotalTokens + stats.TotalCost += t.TotalCost + stats.ToolCallCount += t.ToolCallCount + totalDuration += t.Duration + if t.Model != "" { + modelSet[t.Model] = struct{}{} + } + if t.Flagged { + stats.Flagged = true + } + for _, term := range t.FlaggedTerms { + termSet[term] = struct{}{} + } + if t.UserId != "" { + stats.UserId = t.UserId + } + if t.RecordedAt.Before(stats.FirstSeen) { + stats.FirstSeen = t.RecordedAt + } + if t.RecordedAt.After(stats.LastSeen) { + stats.LastSeen = t.RecordedAt + } + } + stats.AvgDuration = float64(totalDuration.Nanoseconds()) / float64(len(turns)) / 1e6 + for model := range modelSet { + stats.Models = append(stats.Models, model) + } + sort.Strings(stats.Models) + for term := range termSet { + stats.FlaggedTerms = append(stats.FlaggedTerms, term) + } + sort.Strings(stats.FlaggedTerms) + return stats +} diff --git a/backend/app/repositories/telemetry/sqlite/ai_trace.repository.go b/backend/app/repositories/telemetry/sqlite/ai_trace.repository.go index ec712170..b03d5277 100644 --- a/backend/app/repositories/telemetry/sqlite/ai_trace.repository.go +++ b/backend/app/repositories/telemetry/sqlite/ai_trace.repository.go @@ -50,6 +50,11 @@ type aiTraceRow struct { Attributes sqlitetypes.SQLiteJSONMap `lit:"attributes"` DistributedTraceId *uuid.UUID `lit:"distributed_trace_id"` IsRoot bool `lit:"is_root"` + ConversationId string `lit:"conversation_id"` + ToolCallCount int64 `lit:"tool_call_count"` + ToolNames string `lit:"tool_names"` + Flagged bool `lit:"flagged"` + FlaggedTerms string `lit:"flagged_terms"` } type groupedAiTraceRow struct { @@ -78,12 +83,54 @@ type aiTraceDetailStatsRow struct { AvgOutputTokens float64 `lit:"avg_output_tokens"` } +type conversationStatsRow struct { + ConversationId string `lit:"conversation_id"` + UserId string `lit:"user_id"` + Turns int64 `lit:"turns"` + TotalTokens int64 `lit:"total_tokens"` + TotalCost float64 `lit:"total_cost"` + ToolCallCount int64 `lit:"tool_call_count"` + ToolNames string `lit:"tool_names"` + Models string `lit:"models"` + Flagged bool `lit:"flagged"` + FlaggedTerms string `lit:"flagged_terms"` + FirstSeen string `lit:"first_seen"` + LastSeen string `lit:"last_seen"` +} + +type userConversationRow struct { + UserId string `lit:"user_id"` + Turns int64 `lit:"turns"` + ConvCost float64 `lit:"conv_cost"` + ConvTokens int64 `lit:"conv_tokens"` + ConvFlagged bool `lit:"conv_flagged"` + LastSeen string `lit:"last_seen"` +} + +type conversationCostRow struct { + ConversationId string `lit:"conversation_id"` + TotalCost float64 `lit:"total_cost"` +} + +type modelNameRow struct { + Model string `lit:"model"` +} + +type toolNamesRow struct { + ToolNames string `lit:"tool_names"` +} + func init() { models.ExtensionModelRegistrations = append(models.ExtensionModelRegistrations, func(driver lit.Driver) { lit.RegisterModelWithNaming[aiTraceRow](driver, aiTraceRowNaming{}) lit.RegisterModel[groupedAiTraceRow](driver) lit.RegisterModel[aiTraceDurationRow](driver) lit.RegisterModel[aiTraceDetailStatsRow](driver) + lit.RegisterModel[conversationStatsRow](driver) + lit.RegisterModel[userConversationRow](driver) + lit.RegisterModel[conversationCostRow](driver) + lit.RegisterModel[modelNameRow](driver) + lit.RegisterModel[toolNamesRow](driver) }) } @@ -115,6 +162,11 @@ func aiTraceToRow(t models.AiTrace) aiTraceRow { Attributes: sqlitetypes.NewSQLiteJSONMap(t.Attributes), DistributedTraceId: t.DistributedTraceId, IsRoot: t.IsRoot, + ConversationId: t.ConversationId, + ToolCallCount: t.ToolCallCount, + ToolNames: shared.JoinCSV(t.ToolNames), + Flagged: t.Flagged, + FlaggedTerms: shared.JoinCSV(t.FlaggedTerms), } } @@ -145,6 +197,11 @@ func (r *aiTraceRow) toModel() models.AiTrace { StorageKey: r.StorageKey, DistributedTraceId: r.DistributedTraceId, IsRoot: r.IsRoot, + ConversationId: r.ConversationId, + ToolCallCount: r.ToolCallCount, + ToolNames: shared.SplitCSV(r.ToolNames), + Flagged: r.Flagged, + FlaggedTerms: shared.SplitCSV(r.FlaggedTerms), } if r.Attributes != nil { t.Attributes = map[string]string(r.Attributes) @@ -315,7 +372,8 @@ func (r *aiTraceRepository) FindByTraceName(ctx context.Context, projectId uuid. input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes, distributed_trace_id, is_root + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE project_id = :project_id AND trace_name = :trace_name AND recorded_at >= :from AND recorded_at <= :to ORDER BY %s %s LIMIT :limit OFFSET :offset`, orderBy, sortDir), @@ -390,7 +448,8 @@ func (r *aiTraceRepository) FindById(ctx context.Context, projectId, traceId uui input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes, distributed_trace_id, is_root + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE project_id = :project_id AND id = :id` params := lit.P{"project_id": projectId, "id": traceId} @@ -429,7 +488,8 @@ func (r *aiTraceRepository) FindByDistributedTraceId(ctx context.Context, distri input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, input_cost, output_cost, total_cost, trace_name, user_id, finish_reason, server_name, app_version, - storage_key, attributes, distributed_trace_id, is_root + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms FROM ai_traces WHERE distributed_trace_id = :trace_id AND project_id IN (` + strings.Join(placeholders, ",") + `)` if recordedAt != nil { from, to := shared.DistributedTraceWindowBounds(*recordedAt) @@ -459,6 +519,7 @@ func (r *aiTraceRepository) FindByDistributedTraceId(ctx context.Context, distri &row.InputCost, &row.OutputCost, &row.TotalCost, &row.TraceName, &row.UserId, &row.FinishReason, &row.ServerName, &row.AppVersion, &row.StorageKey, &row.Attributes, &row.DistributedTraceId, &row.IsRoot, + &row.ConversationId, &row.ToolCallCount, &row.ToolNames, &row.Flagged, &row.FlaggedTerms, ); err != nil { return nil, err } @@ -467,4 +528,220 @@ func (r *aiTraceRepository) FindByDistributedTraceId(ctx context.Context, distri return traces, nil } +func (r *aiTraceRepository) FindConversations(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time, page, pageSize int, orderBy, sortDirection, search, userId, model, toolName string, flaggedOnly bool) ([]models.AiConversationStats, int64, *models.AiConversationThresholds, error) { + params := lit.P{"project_id": projectId, "from": sqlitetypes.NewSQLiteTime(fromDate), "to": sqlitetypes.NewSQLiteTime(toDate)} + + baseWhere := "project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND conversation_id != ''" + + // Row-level filters use a semi-join on conversation_id: a conversation + // matches when ANY of its turns matches, and its aggregates still cover + // all turns (a plain WHERE would drop the non-matching turns from the + // sums). + var rowPredicates []string + if search != "" { + rowPredicates = append(rowPredicates, + "(INSTR(LOWER(conversation_id), LOWER(:search)) > 0 OR INSTR(LOWER(user_id), LOWER(:search)) > 0 OR INSTR(LOWER(model), LOWER(:search)) > 0 OR INSTR(LOWER(tool_names), LOWER(:search)) > 0 OR INSTR(LOWER(flagged_terms), LOWER(:search)) > 0)") + params["search"] = search + } + if userId != "" { + rowPredicates = append(rowPredicates, "user_id = :user_id") + params["user_id"] = userId + } + if model != "" { + rowPredicates = append(rowPredicates, "model = :model") + params["model"] = model + } + if toolName != "" { + rowPredicates = append(rowPredicates, "INSTR(',' || tool_names || ',', ',' || :tool_name || ',') > 0") + params["tool_name"] = toolName + } + + whereClause := baseWhere + if len(rowPredicates) > 0 { + whereClause += " AND conversation_id IN (SELECT DISTINCT conversation_id FROM ai_traces WHERE " + + baseWhere + " AND " + strings.Join(rowPredicates, " AND ") + ")" + } + + havingClause := "" + if flaggedOnly { + havingClause = " HAVING MAX(flagged) = 1" + } + + rows, err := lit.SelectNamed[conversationStatsRow](db.TelemetryDB, + `SELECT conversation_id, + MAX(user_id) AS user_id, + COUNT(*) AS turns, + SUM(total_tokens) AS total_tokens, + SUM(total_cost) AS total_cost, + SUM(tool_call_count) AS tool_call_count, + GROUP_CONCAT(DISTINCT tool_names) AS tool_names, + GROUP_CONCAT(DISTINCT model) AS models, + MAX(flagged) AS flagged, + GROUP_CONCAT(DISTINCT flagged_terms) AS flagged_terms, + MIN(recorded_at) AS first_seen, + MAX(recorded_at) AS last_seen + FROM ai_traces WHERE `+whereClause+` + GROUP BY conversation_id`+havingClause, params) + if err != nil { + return nil, 0, nil, err + } + + stats := make([]models.AiConversationStats, 0, len(rows)) + for _, row := range rows { + firstSeen, _ := time.Parse(time.RFC3339Nano, row.FirstSeen) + lastSeen, _ := time.Parse(time.RFC3339Nano, row.LastSeen) + stats = append(stats, models.AiConversationStats{ + ConversationId: row.ConversationId, + UserId: row.UserId, + Turns: row.Turns, + TotalTokens: row.TotalTokens, + TotalCost: row.TotalCost, + ToolCallCount: row.ToolCallCount, + ToolNames: shared.UnionCSV(row.ToolNames), + Models: shared.UnionCSV(row.Models), + Flagged: row.Flagged, + FlaggedTerms: shared.UnionCSV(row.FlaggedTerms), + FirstSeen: firstSeen, + LastSeen: lastSeen, + }) + } + + total := int64(len(stats)) + thresholds := shared.ConversationThresholds(stats) + stats = shared.SortAndPageConversations(stats, orderBy, sortDirection, page, pageSize) + return stats, total, thresholds, nil +} + +func (r *aiTraceRepository) FindByConversationId(ctx context.Context, projectId uuid.UUID, conversationId string, fromDate, toDate time.Time) ([]models.AiTrace, *models.AiConversationDetailStats, error) { + query := `SELECT id, project_id, recorded_at, duration, status_code, + model, response_model, provider, operation, + input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens, + input_cost, output_cost, total_cost, + trace_name, user_id, finish_reason, server_name, app_version, + storage_key, attributes, distributed_trace_id, is_root, + conversation_id, tool_call_count, tool_names, flagged, flagged_terms + FROM ai_traces + WHERE project_id = :project_id AND conversation_id = :conversation_id` + params := lit.P{"project_id": projectId, "conversation_id": conversationId} + if !fromDate.IsZero() { + query += ` AND recorded_at >= :from` + params["from"] = sqlitetypes.NewSQLiteTime(fromDate) + } + if !toDate.IsZero() { + query += ` AND recorded_at <= :to` + params["to"] = sqlitetypes.NewSQLiteTime(toDate) + } + query += ` ORDER BY recorded_at ASC LIMIT 1000` + + rows, err := lit.SelectNamed[aiTraceRow](db.TelemetryDB, query, params) + if err != nil { + return nil, nil, err + } + + traces := make([]models.AiTrace, 0, len(rows)) + for _, row := range rows { + traces = append(traces, row.toModel()) + } + return traces, shared.BuildConversationDetailStats(traces), nil +} + +func (r *aiTraceRepository) FindUserStats(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time, page, pageSize int, orderBy, sortDirection, search string) ([]models.AiUserStats, int64, error) { + params := lit.P{"project_id": projectId, "from": sqlitetypes.NewSQLiteTime(fromDate), "to": sqlitetypes.NewSQLiteTime(toDate)} + + whereClause := "project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND user_id != '' AND conversation_id != ''" + if search != "" { + whereClause += " AND INSTR(LOWER(user_id), LOWER(:search)) > 0" + params["search"] = search + } + + rows, err := lit.SelectNamed[userConversationRow](db.TelemetryDB, + `SELECT user_id, + COUNT(*) AS turns, + SUM(total_cost) AS conv_cost, + SUM(total_tokens) AS conv_tokens, + MAX(flagged) AS conv_flagged, + MAX(recorded_at) AS last_seen + FROM ai_traces WHERE `+whereClause+` + GROUP BY user_id, conversation_id`, params) + if err != nil { + return nil, 0, err + } + + aggRows := make([]shared.UserConversationAgg, 0, len(rows)) + for _, row := range rows { + lastSeen, _ := time.Parse(time.RFC3339Nano, row.LastSeen) + aggRows = append(aggRows, shared.UserConversationAgg{ + UserId: row.UserId, + Turns: row.Turns, + Cost: row.ConvCost, + Tokens: row.ConvTokens, + Flagged: row.ConvFlagged, + LastSeen: lastSeen, + }) + } + + stats := shared.AggregateUserStats(aggRows) + total := int64(len(stats)) + stats = shared.SortAndPageUserStats(stats, orderBy, sortDirection, page, pageSize) + return stats, total, nil +} + +func (r *aiTraceRepository) GetConversationCosts(ctx context.Context, projectId uuid.UUID, conversationIds []string, since time.Time) (map[string]float64, error) { + costs := make(map[string]float64, len(conversationIds)) + if len(conversationIds) == 0 { + return costs, nil + } + params := lit.P{"project_id": projectId, "since": sqlitetypes.NewSQLiteTime(since)} + placeholders := make([]string, len(conversationIds)) + for i, id := range conversationIds { + key := fmt.Sprintf("cid_%d", i) + placeholders[i] = ":" + key + params[key] = id + } + rows, err := lit.SelectNamed[conversationCostRow](db.TelemetryDB, + `SELECT conversation_id, SUM(total_cost) AS total_cost + FROM ai_traces + WHERE project_id = :project_id AND recorded_at >= :since AND conversation_id IN (`+strings.Join(placeholders, ",")+`) + GROUP BY conversation_id`, params) + if err != nil { + return nil, err + } + for _, row := range rows { + costs[row.ConversationId] = row.TotalCost + } + return costs, nil +} + +func (r *aiTraceRepository) ListModels(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time) ([]string, error) { + rows, err := lit.SelectNamed[modelNameRow](db.TelemetryDB, + `SELECT DISTINCT model FROM ai_traces + WHERE project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND model != '' + ORDER BY model LIMIT 200`, + lit.P{"project_id": projectId, "from": sqlitetypes.NewSQLiteTime(fromDate), "to": sqlitetypes.NewSQLiteTime(toDate)}) + if err != nil { + return nil, err + } + names := make([]string, 0, len(rows)) + for _, row := range rows { + names = append(names, row.Model) + } + return names, nil +} + +func (r *aiTraceRepository) ListToolNames(ctx context.Context, projectId uuid.UUID, fromDate, toDate time.Time) ([]string, error) { + rows, err := lit.SelectNamed[toolNamesRow](db.TelemetryDB, + `SELECT DISTINCT tool_names FROM ai_traces + WHERE project_id = :project_id AND recorded_at >= :from AND recorded_at <= :to AND tool_names != '' + LIMIT 500`, + lit.P{"project_id": projectId, "from": sqlitetypes.NewSQLiteTime(fromDate), "to": sqlitetypes.NewSQLiteTime(toDate)}) + if err != nil { + return nil, err + } + values := make([]string, 0, len(rows)) + for _, row := range rows { + values = append(values, row.ToolNames) + } + return shared.UnionCSV(shared.JoinCSV(values)), nil +} + var AiTraceRepository = &aiTraceRepository{} diff --git a/backend/app/repositories/telemetry/testhelper_sqlite_test.go b/backend/app/repositories/telemetry/testhelper_sqlite_test.go index 74c1e2db..01f0421a 100644 --- a/backend/app/repositories/telemetry/testhelper_sqlite_test.go +++ b/backend/app/repositories/telemetry/testhelper_sqlite_test.go @@ -116,9 +116,15 @@ CREATE TABLE IF NOT EXISTS ai_traces ( storage_key TEXT NOT NULL DEFAULT '', attributes TEXT NOT NULL DEFAULT '{}', distributed_trace_id TEXT DEFAULT NULL, - is_root INTEGER NOT NULL DEFAULT 1 + is_root INTEGER NOT NULL DEFAULT 1, + conversation_id TEXT NOT NULL DEFAULT '', + tool_call_count INTEGER NOT NULL DEFAULT 0, + tool_names TEXT NOT NULL DEFAULT '', + flagged INTEGER NOT NULL DEFAULT 0, + flagged_terms TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_ai_traces_project_recorded ON ai_traces(project_id, recorded_at); +CREATE INDEX IF NOT EXISTS idx_ai_traces_project_conversation ON ai_traces(project_id, conversation_id); CREATE TABLE IF NOT EXISTS exception_stack_traces ( id TEXT NOT NULL, diff --git a/backend/app/repositories/transactional/pg/notification_rule.repository.go b/backend/app/repositories/transactional/pg/notification_rule.repository.go index 0923b4b0..ff66d344 100644 --- a/backend/app/repositories/transactional/pg/notification_rule.repository.go +++ b/backend/app/repositories/transactional/pg/notification_rule.repository.go @@ -44,7 +44,7 @@ func (r *notificationRuleRepository) FindEnabledPolledRules(tx *sql.Tx) ([]*mode FROM notification_rules r JOIN notification_channels c ON c.id = r.channel_id WHERE r.enabled = true AND c.enabled = true - AND r.rule_type NOT IN ('new_error', 'error_regression', 'ai_trace_cost')`, + AND r.rule_type NOT IN ('new_error', 'error_regression', 'ai_trace_cost', 'ai_conversation_cost', 'ai_flagged_content')`, ) } @@ -56,7 +56,7 @@ func (r *notificationRuleRepository) FindEnabledEventRules(tx *sql.Tx, projectId FROM notification_rules r JOIN notification_channels c ON c.id = r.channel_id WHERE r.project_id = :project_id AND r.enabled = true AND c.enabled = true - AND r.rule_type IN ('new_error', 'error_regression', 'ai_trace_cost')`, + AND r.rule_type IN ('new_error', 'error_regression', 'ai_trace_cost', 'ai_conversation_cost', 'ai_flagged_content')`, lit.P{"project_id": projectId}, ) } diff --git a/backend/app/repositories/transactional/pg/project.repository.go b/backend/app/repositories/transactional/pg/project.repository.go index 9373aa75..ee8c04b8 100644 --- a/backend/app/repositories/transactional/pg/project.repository.go +++ b/backend/app/repositories/transactional/pg/project.repository.go @@ -10,6 +10,7 @@ import ( "github.com/tracewayapp/traceway/backend/app/db" "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/services/contentflag" "github.com/google/uuid" "github.com/tracewayapp/lit/v2" @@ -28,6 +29,8 @@ type projectWithRole struct { DropHealthyHealthchecks bool `lit:"drop_healthy_healthchecks"` HealthcheckPaths models.StringSlice `lit:"healthcheck_paths"` ProfileLabelAllowlist models.StringSlice `lit:"profile_label_allowlist"` + AiFlaggedTerms models.StringSlice `lit:"ai_flagged_terms"` + AiFlaggedLanguages models.StringSlice `lit:"ai_flagged_languages"` Role string `lit:"role"` OverrideRole *string `lit:"override_role"` } @@ -57,7 +60,7 @@ func effectiveRole(orgRole string, overrideRole *string) string { func (p *projectRepository) FindAllWithBackendUrlByUserId(tx *sql.Tx, userId int) ([]*models.ProjectWithBackendUrl, error) { rows, err := lit.SelectNamed[projectWithRole]( tx, - `SELECT DISTINCT p.id, p.name, p.token, p.framework, p.organization_id, p.created_at, p.source_map_token, p.drop_healthy_healthchecks, p.healthcheck_paths, p.profile_label_allowlist, ou.role, pur.role as override_role + `SELECT DISTINCT p.id, p.name, p.token, p.framework, p.organization_id, p.created_at, p.source_map_token, p.drop_healthy_healthchecks, p.healthcheck_paths, p.profile_label_allowlist, p.ai_flagged_terms, p.ai_flagged_languages, ou.role, pur.role as override_role FROM projects p INNER JOIN organization_users ou ON p.organization_id = ou.organization_id LEFT JOIN project_user_roles pur ON pur.project_id = p.id AND pur.user_id = :user_id @@ -90,6 +93,8 @@ func (p *projectRepository) FindAllWithBackendUrlByUserId(tx *sql.Tx, userId int DropHealthyHealthchecks: row.DropHealthyHealthchecks, HealthcheckPaths: row.HealthcheckPaths, ProfileLabelAllowlist: row.ProfileLabelAllowlist, + AiFlaggedTerms: row.AiFlaggedTerms, + AiFlaggedLanguages: row.AiFlaggedLanguages, } projectWithUrl := project.ToProjectWithBackendUrl() projectWithUrl.Role = role @@ -121,14 +126,14 @@ func (p *projectRepository) GetEffectiveRole(tx *sql.Tx, projectId uuid.UUID, us func (p *projectRepository) FindAll(tx *sql.Tx) ([]*models.Project, error) { return lit.Select[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects ORDER BY created_at ASC", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects ORDER BY created_at ASC", ) } func (p *projectRepository) FindByToken(tx *sql.Tx, token string) (*models.Project, error) { return lit.SelectSingleNamed[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects WHERE token = :token", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects WHERE token = :token", lit.P{"token": token}, ) } @@ -136,7 +141,7 @@ func (p *projectRepository) FindByToken(tx *sql.Tx, token string) (*models.Proje func (p *projectRepository) FindById(tx *sql.Tx, id uuid.UUID) (*models.Project, error) { return lit.SelectSingleNamed[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects WHERE id = :id", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects WHERE id = :id", lit.P{"id": id}, ) } @@ -149,6 +154,7 @@ func (p *projectRepository) Create(tx *sql.Tx, name string, framework string) (* Framework: framework, CreatedAt: time.Now().UTC(), DropHealthyHealthchecks: true, + AiFlaggedLanguages: models.StringSlice(contentflag.DefaultLanguages), } err := lit.InsertExistingUuid(tx, project) @@ -169,6 +175,8 @@ func (p *projectRepository) CreateWithOrganization(tx *sql.Tx, name string, fram CreatedAt: time.Now().UTC(), DropHealthyHealthchecks: true, ProfileLabelAllowlist: models.StringSlice{}, + AiFlaggedTerms: models.StringSlice{}, + AiFlaggedLanguages: models.StringSlice(contentflag.DefaultLanguages), } if frameworkRequiresSymbolUpload(framework) { @@ -187,7 +195,7 @@ func (p *projectRepository) CreateWithOrganization(tx *sql.Tx, name string, fram func (p *projectRepository) FindByOrganizationId(tx *sql.Tx, organizationId int) ([]*models.Project, error) { return lit.SelectNamed[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects WHERE organization_id = :org_id ORDER BY created_at ASC", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects WHERE organization_id = :org_id ORDER BY created_at ASC", lit.P{"org_id": organizationId}, ) } @@ -195,7 +203,7 @@ func (p *projectRepository) FindByOrganizationId(tx *sql.Tx, organizationId int) func (p *projectRepository) FindByUserId(tx *sql.Tx, userId int) ([]*models.Project, error) { return lit.SelectNamed[models.Project]( tx, - `SELECT DISTINCT p.id, p.name, p.token, p.framework, p.organization_id, p.created_at, p.source_map_token, p.drop_healthy_healthchecks, p.healthcheck_paths, p.profile_label_allowlist + `SELECT DISTINCT p.id, p.name, p.token, p.framework, p.organization_id, p.created_at, p.source_map_token, p.drop_healthy_healthchecks, p.healthcheck_paths, p.profile_label_allowlist, p.ai_flagged_terms, p.ai_flagged_languages FROM projects p INNER JOIN organization_users ou ON p.organization_id = ou.organization_id WHERE ou.user_id = :user_id @@ -241,7 +249,7 @@ func (p *projectRepository) GenerateSourceMapToken(tx *sql.Tx, projectId uuid.UU return token, nil } -func (p *projectRepository) Update(tx *sql.Tx, id uuid.UUID, name string, framework string, dropHealthyHealthchecks *bool, healthcheckPaths *[]string, profileLabelAllowlist *[]string) (*models.Project, error) { +func (p *projectRepository) Update(tx *sql.Tx, id uuid.UUID, name string, framework string, dropHealthyHealthchecks *bool, healthcheckPaths *[]string, profileLabelAllowlist *[]string, aiFlaggedTerms *[]string, aiFlaggedLanguages *[]string) (*models.Project, error) { project, err := p.FindById(tx, id) if err != nil { return nil, err @@ -260,6 +268,12 @@ func (p *projectRepository) Update(tx *sql.Tx, id uuid.UUID, name string, framew if profileLabelAllowlist != nil { project.ProfileLabelAllowlist = models.StringSlice(*profileLabelAllowlist) } + if aiFlaggedTerms != nil { + project.AiFlaggedTerms = models.StringSlice(*aiFlaggedTerms) + } + if aiFlaggedLanguages != nil { + project.AiFlaggedLanguages = models.StringSlice(*aiFlaggedLanguages) + } err = lit.UpdateNamed[models.Project](tx, project, "id = :id", lit.P{"id": id}) if err != nil { return nil, err @@ -289,7 +303,7 @@ func (p *projectRepository) Delete(tx *sql.Tx, id uuid.UUID) error { func (p *projectRepository) FindBySourceMapToken(tx *sql.Tx, token string) (*models.Project, error) { return lit.SelectSingleNamed[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects WHERE source_map_token = :smt", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects WHERE source_map_token = :smt", lit.P{"smt": token}, ) } diff --git a/backend/app/repositories/transactional/project.repository_test.go b/backend/app/repositories/transactional/project.repository_test.go index 0fa2b428..a2ab47bc 100644 --- a/backend/app/repositories/transactional/project.repository_test.go +++ b/backend/app/repositories/transactional/project.repository_test.go @@ -20,7 +20,9 @@ func setupProjectsTable(t *testing.T) { created_at DATETIME NOT NULL DEFAULT (datetime('now')), drop_healthy_healthchecks INTEGER NOT NULL DEFAULT 1, healthcheck_paths TEXT NOT NULL DEFAULT '[]', - profile_label_allowlist TEXT NOT NULL DEFAULT '[]' + profile_label_allowlist TEXT NOT NULL DEFAULT '[]', + ai_flagged_terms TEXT NOT NULL DEFAULT '[]', + ai_flagged_languages TEXT NOT NULL DEFAULT '["en"]' )`) if err != nil { t.Fatalf("failed to create projects table: %v", err) @@ -59,10 +61,16 @@ func TestProjectHealthcheckFieldsRoundTrip(t *testing.T) { t.Errorf("HealthcheckPaths = %v, expected empty", found.HealthcheckPaths) } + if len(created.AiFlaggedLanguages) != 1 || created.AiFlaggedLanguages[0] != "en" { + t.Errorf("new project AiFlaggedLanguages = %v, expected [en]", created.AiFlaggedLanguages) + } + disable := false paths := []string{"/internal/probe", "/checks/*"} labels := []string{"tenant", "region"} - updated, err := ProjectRepository.Update(tx, created.Id, "test-project", "gin", &disable, &paths, &labels) + flaggedTerms := []string{"acmecorp", "secret phrase"} + flaggedLanguages := []string{"en", "sr"} + updated, err := ProjectRepository.Update(tx, created.Id, "test-project", "gin", &disable, &paths, &labels, &flaggedTerms, &flaggedLanguages) if err != nil { t.Fatalf("failed to update project: %v", err) } @@ -83,9 +91,15 @@ func TestProjectHealthcheckFieldsRoundTrip(t *testing.T) { if len(found.ProfileLabelAllowlist) != 2 || found.ProfileLabelAllowlist[0] != "tenant" || found.ProfileLabelAllowlist[1] != "region" { t.Errorf("ProfileLabelAllowlist = %v, expected %v", found.ProfileLabelAllowlist, labels) } + if len(found.AiFlaggedTerms) != 2 || found.AiFlaggedTerms[0] != "acmecorp" || found.AiFlaggedTerms[1] != "secret phrase" { + t.Errorf("AiFlaggedTerms = %v, expected %v", found.AiFlaggedTerms, flaggedTerms) + } + if len(found.AiFlaggedLanguages) != 2 || found.AiFlaggedLanguages[0] != "en" || found.AiFlaggedLanguages[1] != "sr" { + t.Errorf("AiFlaggedLanguages = %v, expected %v", found.AiFlaggedLanguages, flaggedLanguages) + } keepDrop := true - updated, err = ProjectRepository.Update(tx, created.Id, "renamed", "gin", &keepDrop, nil, nil) + updated, err = ProjectRepository.Update(tx, created.Id, "renamed", "gin", &keepDrop, nil, nil, nil, nil) if err != nil { t.Fatalf("failed to update project without paths: %v", err) } @@ -95,4 +109,10 @@ func TestProjectHealthcheckFieldsRoundTrip(t *testing.T) { if len(updated.ProfileLabelAllowlist) != 2 { t.Errorf("nil profileLabelAllowlist should keep existing value, got %v", updated.ProfileLabelAllowlist) } + if len(updated.AiFlaggedTerms) != 2 { + t.Errorf("nil aiFlaggedTerms should keep existing value, got %v", updated.AiFlaggedTerms) + } + if len(updated.AiFlaggedLanguages) != 2 { + t.Errorf("nil aiFlaggedLanguages should keep existing value, got %v", updated.AiFlaggedLanguages) + } } diff --git a/backend/app/repositories/transactional/sqlite/notification_rule.repository.go b/backend/app/repositories/transactional/sqlite/notification_rule.repository.go index 8722a201..4df3f858 100644 --- a/backend/app/repositories/transactional/sqlite/notification_rule.repository.go +++ b/backend/app/repositories/transactional/sqlite/notification_rule.repository.go @@ -44,7 +44,7 @@ func (r *notificationRuleRepository) FindEnabledPolledRules(tx *sql.Tx) ([]*mode FROM notification_rules r JOIN notification_channels c ON c.id = r.channel_id WHERE r.enabled = true AND c.enabled = true - AND r.rule_type NOT IN ('new_error', 'error_regression', 'ai_trace_cost')`, + AND r.rule_type NOT IN ('new_error', 'error_regression', 'ai_trace_cost', 'ai_conversation_cost', 'ai_flagged_content')`, ) } @@ -56,7 +56,7 @@ func (r *notificationRuleRepository) FindEnabledEventRules(tx *sql.Tx, projectId FROM notification_rules r JOIN notification_channels c ON c.id = r.channel_id WHERE r.project_id = :project_id AND r.enabled = true AND c.enabled = true - AND r.rule_type IN ('new_error', 'error_regression', 'ai_trace_cost')`, + AND r.rule_type IN ('new_error', 'error_regression', 'ai_trace_cost', 'ai_conversation_cost', 'ai_flagged_content')`, lit.P{"project_id": projectId}, ) } diff --git a/backend/app/repositories/transactional/sqlite/project.repository.go b/backend/app/repositories/transactional/sqlite/project.repository.go index f463694e..533aac0a 100644 --- a/backend/app/repositories/transactional/sqlite/project.repository.go +++ b/backend/app/repositories/transactional/sqlite/project.repository.go @@ -10,6 +10,7 @@ import ( "github.com/tracewayapp/traceway/backend/app/db" "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/services/contentflag" "github.com/google/uuid" "github.com/tracewayapp/lit/v2" @@ -28,6 +29,8 @@ type projectWithRole struct { DropHealthyHealthchecks bool `lit:"drop_healthy_healthchecks"` HealthcheckPaths models.StringSlice `lit:"healthcheck_paths"` ProfileLabelAllowlist models.StringSlice `lit:"profile_label_allowlist"` + AiFlaggedTerms models.StringSlice `lit:"ai_flagged_terms"` + AiFlaggedLanguages models.StringSlice `lit:"ai_flagged_languages"` Role string `lit:"role"` OverrideRole *string `lit:"override_role"` } @@ -57,7 +60,7 @@ func effectiveRole(orgRole string, overrideRole *string) string { func (p *projectRepository) FindAllWithBackendUrlByUserId(tx *sql.Tx, userId int) ([]*models.ProjectWithBackendUrl, error) { rows, err := lit.SelectNamed[projectWithRole]( tx, - `SELECT DISTINCT p.id, p.name, p.token, p.framework, p.organization_id, p.created_at, p.source_map_token, p.drop_healthy_healthchecks, p.healthcheck_paths, p.profile_label_allowlist, ou.role, pur.role as override_role + `SELECT DISTINCT p.id, p.name, p.token, p.framework, p.organization_id, p.created_at, p.source_map_token, p.drop_healthy_healthchecks, p.healthcheck_paths, p.profile_label_allowlist, p.ai_flagged_terms, p.ai_flagged_languages, ou.role, pur.role as override_role FROM projects p INNER JOIN organization_users ou ON p.organization_id = ou.organization_id LEFT JOIN project_user_roles pur ON pur.project_id = p.id AND pur.user_id = :user_id @@ -90,6 +93,8 @@ func (p *projectRepository) FindAllWithBackendUrlByUserId(tx *sql.Tx, userId int DropHealthyHealthchecks: row.DropHealthyHealthchecks, HealthcheckPaths: row.HealthcheckPaths, ProfileLabelAllowlist: row.ProfileLabelAllowlist, + AiFlaggedTerms: row.AiFlaggedTerms, + AiFlaggedLanguages: row.AiFlaggedLanguages, } projectWithUrl := project.ToProjectWithBackendUrl() projectWithUrl.Role = role @@ -121,14 +126,14 @@ func (p *projectRepository) GetEffectiveRole(tx *sql.Tx, projectId uuid.UUID, us func (p *projectRepository) FindAll(tx *sql.Tx) ([]*models.Project, error) { return lit.Select[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects ORDER BY created_at ASC", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects ORDER BY created_at ASC", ) } func (p *projectRepository) FindByToken(tx *sql.Tx, token string) (*models.Project, error) { return lit.SelectSingleNamed[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects WHERE token = :token", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects WHERE token = :token", lit.P{"token": token}, ) } @@ -136,7 +141,7 @@ func (p *projectRepository) FindByToken(tx *sql.Tx, token string) (*models.Proje func (p *projectRepository) FindById(tx *sql.Tx, id uuid.UUID) (*models.Project, error) { return lit.SelectSingleNamed[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects WHERE id = :id", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects WHERE id = :id", lit.P{"id": id}, ) } @@ -149,6 +154,7 @@ func (p *projectRepository) Create(tx *sql.Tx, name string, framework string) (* Framework: framework, CreatedAt: time.Now().UTC(), DropHealthyHealthchecks: true, + AiFlaggedLanguages: models.StringSlice(contentflag.DefaultLanguages), } err := lit.InsertExistingUuid(tx, project) @@ -169,6 +175,8 @@ func (p *projectRepository) CreateWithOrganization(tx *sql.Tx, name string, fram CreatedAt: time.Now().UTC(), DropHealthyHealthchecks: true, ProfileLabelAllowlist: models.StringSlice{}, + AiFlaggedTerms: models.StringSlice{}, + AiFlaggedLanguages: models.StringSlice(contentflag.DefaultLanguages), } if frameworkRequiresSymbolUpload(framework) { @@ -187,7 +195,7 @@ func (p *projectRepository) CreateWithOrganization(tx *sql.Tx, name string, fram func (p *projectRepository) FindByOrganizationId(tx *sql.Tx, organizationId int) ([]*models.Project, error) { return lit.SelectNamed[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects WHERE organization_id = :org_id ORDER BY created_at ASC", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects WHERE organization_id = :org_id ORDER BY created_at ASC", lit.P{"org_id": organizationId}, ) } @@ -195,7 +203,7 @@ func (p *projectRepository) FindByOrganizationId(tx *sql.Tx, organizationId int) func (p *projectRepository) FindByUserId(tx *sql.Tx, userId int) ([]*models.Project, error) { return lit.SelectNamed[models.Project]( tx, - `SELECT DISTINCT p.id, p.name, p.token, p.framework, p.organization_id, p.created_at, p.source_map_token, p.drop_healthy_healthchecks, p.healthcheck_paths, p.profile_label_allowlist + `SELECT DISTINCT p.id, p.name, p.token, p.framework, p.organization_id, p.created_at, p.source_map_token, p.drop_healthy_healthchecks, p.healthcheck_paths, p.profile_label_allowlist, p.ai_flagged_terms, p.ai_flagged_languages FROM projects p INNER JOIN organization_users ou ON p.organization_id = ou.organization_id WHERE ou.user_id = :user_id @@ -241,7 +249,7 @@ func (p *projectRepository) GenerateSourceMapToken(tx *sql.Tx, projectId uuid.UU return token, nil } -func (p *projectRepository) Update(tx *sql.Tx, id uuid.UUID, name string, framework string, dropHealthyHealthchecks *bool, healthcheckPaths *[]string, profileLabelAllowlist *[]string) (*models.Project, error) { +func (p *projectRepository) Update(tx *sql.Tx, id uuid.UUID, name string, framework string, dropHealthyHealthchecks *bool, healthcheckPaths *[]string, profileLabelAllowlist *[]string, aiFlaggedTerms *[]string, aiFlaggedLanguages *[]string) (*models.Project, error) { project, err := p.FindById(tx, id) if err != nil { return nil, err @@ -260,6 +268,12 @@ func (p *projectRepository) Update(tx *sql.Tx, id uuid.UUID, name string, framew if profileLabelAllowlist != nil { project.ProfileLabelAllowlist = models.StringSlice(*profileLabelAllowlist) } + if aiFlaggedTerms != nil { + project.AiFlaggedTerms = models.StringSlice(*aiFlaggedTerms) + } + if aiFlaggedLanguages != nil { + project.AiFlaggedLanguages = models.StringSlice(*aiFlaggedLanguages) + } err = lit.UpdateNamed[models.Project](tx, project, "id = :id", lit.P{"id": id}) if err != nil { return nil, err @@ -289,7 +303,7 @@ func (p *projectRepository) Delete(tx *sql.Tx, id uuid.UUID) error { func (p *projectRepository) FindBySourceMapToken(tx *sql.Tx, token string) (*models.Project, error) { return lit.SelectSingleNamed[models.Project]( tx, - "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist FROM projects WHERE source_map_token = :smt", + "SELECT id, name, token, framework, organization_id, created_at, source_map_token, drop_healthy_healthchecks, healthcheck_paths, profile_label_allowlist, ai_flagged_terms, ai_flagged_languages FROM projects WHERE source_map_token = :smt", lit.P{"smt": token}, ) } diff --git a/backend/app/services/contentflag/contentflag.go b/backend/app/services/contentflag/contentflag.go new file mode 100644 index 00000000..4a4f4be3 --- /dev/null +++ b/backend/app/services/contentflag/contentflag.go @@ -0,0 +1,224 @@ +package contentflag + +import ( + "embed" + "sort" + "strings" + "sync" + "unicode" +) + +const ( + // maxScanBytes bounds per-text CPU on the ingest hot path; conversation + // payloads can reach megabytes. + maxScanBytes = 256 * 1024 + maxMatches = 20 +) + +// Built-in language packs, one term per line. Deliberately conservative +// lists of common profanity; operators extend them per project via custom +// terms and pick which packs apply (projects.ai_flagged_languages). +// Word-boundary matching tokenizes on non-letter/digit runes, so packs only +// work for languages that separate words (no CJK). +// +//go:embed terms/*.txt +var termFiles embed.FS + +// DefaultLanguages is what projects get when they have not chosen packs +// (and what the ingest falls back to when no project is resolvable). +var DefaultLanguages = []string{"en"} + +var languagePacks = loadLanguagePacks() + +func loadLanguagePacks() map[string][]string { + packs := map[string][]string{} + entries, err := termFiles.ReadDir("terms") + if err != nil { + panic("contentflag: embedded terms directory missing: " + err.Error()) + } + for _, entry := range entries { + lang := strings.TrimSuffix(entry.Name(), ".txt") + data, err := termFiles.ReadFile("terms/" + entry.Name()) + if err != nil { + panic("contentflag: reading " + entry.Name() + ": " + err.Error()) + } + var terms []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line != "" { + terms = append(terms, line) + } + } + packs[lang] = terms + } + return packs +} + +// AvailableLanguages returns the built-in pack codes, sorted. +func AvailableLanguages() []string { + langs := make([]string, 0, len(languagePacks)) + for lang := range languagePacks { + langs = append(langs, lang) + } + sort.Strings(langs) + return langs +} + +// IsValidLanguage reports whether a built-in pack exists for the code. +func IsValidLanguage(code string) bool { + _, ok := languagePacks[code] + return ok +} + +// Matcher scans text for flagged terms using whole-token matching, so a +// term never matches inside a larger word (no Scunthorpe-style false +// positives). +type Matcher struct { + words map[string]struct{} + phrases [][]string + maxPhraseLen int +} + +// packMatcherCache caches matchers for language-pack-only configurations +// (no custom terms), keyed by the canonical language list. The set of +// distinct configurations per process is tiny. +var packMatcherCache sync.Map + +// NewMatcher returns a matcher over the selected language packs merged with +// the given per-project custom terms. nil languages means DefaultLanguages; +// an explicitly empty (non-nil) slice means no built-in packs, custom terms +// only. Unknown language codes are ignored. +func NewMatcher(languages []string, customTerms []string) *Matcher { + if languages == nil { + languages = DefaultLanguages + } + if len(customTerms) == 0 { + key := canonicalLanguageKey(languages) + if cached, ok := packMatcherCache.Load(key); ok { + return cached.(*Matcher) + } + m := buildMatcher(languages, nil) + packMatcherCache.Store(key, m) + return m + } + return buildMatcher(languages, customTerms) +} + +func canonicalLanguageKey(languages []string) string { + sorted := append([]string(nil), languages...) + sort.Strings(sorted) + return strings.Join(sorted, ",") +} + +func buildMatcher(languages []string, customTerms []string) *Matcher { + m := &Matcher{words: make(map[string]struct{})} + for _, lang := range languages { + for _, term := range languagePacks[lang] { + m.add(term) + } + } + for _, term := range customTerms { + m.add(term) + } + return m +} + +func (m *Matcher) add(term string) { + tokens := tokenize(term, 0) + switch len(tokens) { + case 0: + case 1: + m.words[tokens[0]] = struct{}{} + default: + m.phrases = append(m.phrases, tokens) + if len(tokens) > m.maxPhraseLen { + m.maxPhraseLen = len(tokens) + } + } +} + +// Scan returns the sorted, deduplicated flagged terms found in the given +// texts, capped at maxMatches. Phrase terms are reported space-joined. +func (m *Matcher) Scan(texts ...string) []string { + var found map[string]struct{} + record := func(term string) { + if found == nil { + found = make(map[string]struct{}) + } + found[term] = struct{}{} + } + + for _, text := range texts { + if len(found) >= maxMatches { + break + } + if text == "" { + continue + } + if len(text) > maxScanBytes { + text = text[:maxScanBytes] + } + tokens := tokenize(text, maxScanBytes) + for i, token := range tokens { + if len(found) >= maxMatches { + break + } + if _, ok := m.words[token]; ok { + record(token) + } + for _, phrase := range m.phrases { + if i+len(phrase) > len(tokens) { + continue + } + matched := true + for j, want := range phrase { + if tokens[i+j] != want { + matched = false + break + } + } + if matched { + record(strings.Join(phrase, " ")) + } + } + } + } + + if len(found) == 0 { + return nil + } + terms := make([]string, 0, len(found)) + for term := range found { + terms = append(terms, term) + } + sort.Strings(terms) + if len(terms) > maxMatches { + terms = terms[:maxMatches] + } + return terms +} + +// tokenize lowercases and splits on any rune that is not a letter or digit. +// A byte limit of 0 means no limit. +func tokenize(s string, limit int) []string { + var tokens []string + var current strings.Builder + flush := func() { + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + } + for i, r := range s { + if limit > 0 && i >= limit { + break + } + if unicode.IsLetter(r) || unicode.IsDigit(r) { + current.WriteRune(unicode.ToLower(r)) + } else { + flush() + } + } + flush() + return tokens +} diff --git a/backend/app/services/contentflag/contentflag_test.go b/backend/app/services/contentflag/contentflag_test.go new file mode 100644 index 00000000..5103ea56 --- /dev/null +++ b/backend/app/services/contentflag/contentflag_test.go @@ -0,0 +1,166 @@ +package contentflag + +import ( + "reflect" + "strings" + "testing" +) + +func TestScanFindsBuiltinTerms(t *testing.T) { + m := NewMatcher(nil, nil) + got := m.Scan("well this is complete bullshit and I hate it") + want := []string{"bullshit"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestScanIsCaseInsensitive(t *testing.T) { + m := NewMatcher(nil, nil) + got := m.Scan("What the FUCK", "this is Shit") + want := []string{"fuck", "shit"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestScanWordBoundaries(t *testing.T) { + m := NewMatcher(nil, nil) + // None of these contain a flagged term as a whole token. + got := m.Scan( + "the Scunthorpe assessment covered classic hancock analysis", + "the shipment passed inspection", + "a cocktail of assumptions", + ) + if got != nil { + t.Fatalf("expected no matches, got %v", got) + } +} + +func TestScanPhrases(t *testing.T) { + m := NewMatcher(nil, nil) + got := m.Scan("you son of a bitch, I'm in") + want := []string{"bitch", "son of a bitch"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestScanCustomTerms(t *testing.T) { + m := NewMatcher(nil, []string{"acmecorp", "Secret Project"}) + got := m.Scan("mentioning AcmeCorp and the secret project here") + want := []string{"acmecorp", "secret project"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestScanCustomDoesNotReplaceBuiltins(t *testing.T) { + m := NewMatcher(nil, []string{"acmecorp"}) + got := m.Scan("acmecorp is shit") + want := []string{"acmecorp", "shit"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestLanguagePacks(t *testing.T) { + spanish := NewMatcher([]string{"es"}, nil) + if got := spanish.Scan("esto es una mierda, hijo de puta"); !reflect.DeepEqual(got, []string{"hijo de puta", "mierda", "puta"}) { + t.Errorf("es pack: got %v", got) + } + // The Spanish pack must not flag English profanity. + if got := spanish.Scan("this is bullshit"); got != nil { + t.Errorf("es pack should not match English terms, got %v", got) + } + + german := NewMatcher([]string{"de"}, nil) + if got := german.Scan("so eine Scheiße, du Arschloch"); !reflect.DeepEqual(got, []string{"arschloch", "scheiße"}) { + t.Errorf("de pack: got %v", got) + } + + serbian := NewMatcher([]string{"sr"}, nil) + if got := serbian.Scan("kakvo sranje, jebote"); !reflect.DeepEqual(got, []string{"jebote", "sranje"}) { + t.Errorf("sr pack: got %v", got) + } + + multi := NewMatcher([]string{"en", "es", "de"}, nil) + got := multi.Scan("bullshit y mierda und Scheiße") + if !reflect.DeepEqual(got, []string{"bullshit", "mierda", "scheiße"}) { + t.Errorf("multi pack: got %v", got) + } +} + +func TestExplicitEmptyLanguagesMeansCustomOnly(t *testing.T) { + m := NewMatcher([]string{}, []string{"acmecorp"}) + if got := m.Scan("this is bullshit about acmecorp"); !reflect.DeepEqual(got, []string{"acmecorp"}) { + t.Errorf("expected custom-only match, got %v", got) + } +} + +func TestUnknownLanguageIgnored(t *testing.T) { + m := NewMatcher([]string{"xx", "en"}, nil) + if got := m.Scan("this is bullshit"); !reflect.DeepEqual(got, []string{"bullshit"}) { + t.Errorf("unknown pack code should be ignored, got %v", got) + } +} + +func TestAvailableLanguages(t *testing.T) { + langs := AvailableLanguages() + want := []string{"de", "en", "es", "fr", "it", "pt", "sr"} + if !reflect.DeepEqual(langs, want) { + t.Errorf("AvailableLanguages = %v, want %v", langs, want) + } + if !IsValidLanguage("en") || IsValidLanguage("xx") { + t.Error("IsValidLanguage misbehaving") + } +} + +func TestScanEmptyAndNil(t *testing.T) { + m := NewMatcher(nil, nil) + if got := m.Scan(); got != nil { + t.Fatalf("expected nil for no texts, got %v", got) + } + if got := m.Scan("", ""); got != nil { + t.Fatalf("expected nil for empty texts, got %v", got) + } + if got := m.Scan("a perfectly polite conversation"); got != nil { + t.Fatalf("expected nil for clean text, got %v", got) + } +} + +func TestScanCapsMatches(t *testing.T) { + terms := make([]string, 0, maxMatches+10) + var text strings.Builder + for i := 0; i < maxMatches+10; i++ { + term := "customterm" + strings.Repeat("x", i+1) + terms = append(terms, term) + text.WriteString(term + " ") + } + m := NewMatcher(nil, terms) + got := m.Scan(text.String()) + if len(got) > maxMatches { + t.Fatalf("expected at most %d matches, got %d", maxMatches, len(got)) + } +} + +func TestScanTruncatesLargeText(t *testing.T) { + // The flagged term sits beyond the scan cap and must not be found. + text := strings.Repeat("clean ", maxScanBytes/6+1) + " bullshit" + m := NewMatcher(nil, nil) + if got := m.Scan(text); got != nil { + t.Fatalf("expected no matches beyond the scan cap, got %v", got) + } +} + +func TestNewMatcherSharedForSamePacks(t *testing.T) { + if NewMatcher(nil, nil) != NewMatcher(nil, nil) { + t.Fatal("expected the default matcher to be shared") + } + if NewMatcher([]string{"en"}, nil) != NewMatcher(nil, nil) { + t.Fatal("expected explicit [en] to share the default matcher") + } + if NewMatcher([]string{"es", "de"}, nil) != NewMatcher([]string{"de", "es"}, nil) { + t.Fatal("expected language order not to matter for the cache") + } +} diff --git a/backend/app/services/contentflag/terms/de.txt b/backend/app/services/contentflag/terms/de.txt new file mode 100644 index 00000000..640bef4b --- /dev/null +++ b/backend/app/services/contentflag/terms/de.txt @@ -0,0 +1,22 @@ +arschloch +arsch +drecksau +dreckskerl +fick +ficken +fotze +hure +hurensohn +kacke +miststück +mistkerl +scheiss +scheiß +scheisse +scheiße +schlampe +schwanzlutscher +schwuchtel +verdammt +verfickt +wichser diff --git a/backend/app/services/contentflag/terms/en.txt b/backend/app/services/contentflag/terms/en.txt new file mode 100644 index 00000000..1521fd46 --- /dev/null +++ b/backend/app/services/contentflag/terms/en.txt @@ -0,0 +1,34 @@ +arse +arsehole +ass +asshole +bastard +bitch +bollocks +bullshit +cock +crap +cunt +damn +dick +dickhead +douchebag +dumbass +fuck +fucked +fucker +fucking +goddamn +jackass +motherfucker +piss +pissed +prick +pussy +shit +shitty +slut +son of a bitch +twat +wanker +whore diff --git a/backend/app/services/contentflag/terms/es.txt b/backend/app/services/contentflag/terms/es.txt new file mode 100644 index 00000000..f39ab7b2 --- /dev/null +++ b/backend/app/services/contentflag/terms/es.txt @@ -0,0 +1,29 @@ +cabron +cabrón +carajo +chinga +chingar +chingada +cojones +coño +cono +culero +follar +gilipollas +hijo de puta +hija de puta +hostia +joder +jodido +mamon +mamón +me cago en +mierda +pendejo +pendeja +pinche +polla +puta +puto +verga +zorra diff --git a/backend/app/services/contentflag/terms/fr.txt b/backend/app/services/contentflag/terms/fr.txt new file mode 100644 index 00000000..1c9c3235 --- /dev/null +++ b/backend/app/services/contentflag/terms/fr.txt @@ -0,0 +1,22 @@ +batard +bâtard +bite +bordel +chier +chiant +connard +connasse +couilles +encule +enculé +foutre +merde +nique ta mere +nique ta mère +niquer +pouffiasse +putain +pute +salaud +salope +ta gueule diff --git a/backend/app/services/contentflag/terms/it.txt b/backend/app/services/contentflag/terms/it.txt new file mode 100644 index 00000000..12b2fb4c --- /dev/null +++ b/backend/app/services/contentflag/terms/it.txt @@ -0,0 +1,16 @@ +bastardo +bastarda +cazzo +coglione +coglioni +figa +merda +minchia +porca puttana +porco dio +puttana +stronza +stronzata +stronzo +troia +vaffanculo diff --git a/backend/app/services/contentflag/terms/pt.txt b/backend/app/services/contentflag/terms/pt.txt new file mode 100644 index 00000000..7061c07f --- /dev/null +++ b/backend/app/services/contentflag/terms/pt.txt @@ -0,0 +1,21 @@ +babaca +bosta +buceta +cacete +caralho +corno +cu +cuzao +cuzão +filho da puta +filha da puta +foda +foda-se +foder +fodido +merda +porra +puta +puto +vai se foder +viado diff --git a/backend/app/services/contentflag/terms/sr.txt b/backend/app/services/contentflag/terms/sr.txt new file mode 100644 index 00000000..3c6f4bc8 --- /dev/null +++ b/backend/app/services/contentflag/terms/sr.txt @@ -0,0 +1,25 @@ +drkadzija +drkadžija +govno +jebac +jebač +jebem +jebem ti mater +jeben +jebeno +jebi se +jebiga +jebote +kurac +kurcina +kurva +picka +pička +picka ti materina +pička ti materina +pickica +pizda +seronja +sranje +supak +šupak diff --git a/backend/cmd/seed.go b/backend/cmd/seed.go index 70db7955..5cf513f8 100644 --- a/backend/cmd/seed.go +++ b/backend/cmd/seed.go @@ -9,6 +9,7 @@ import ( "github.com/tracewayapp/traceway/backend/app/models" "github.com/tracewayapp/traceway/backend/app/repositories/transactional" "github.com/tracewayapp/traceway/backend/app/services" + "github.com/tracewayapp/traceway/backend/app/services/contentflag" "github.com/google/uuid" "github.com/tracewayapp/lit/v2" @@ -51,12 +52,13 @@ func seed(opts *options) error { for _, p := range opts.defaultProjects { project := &models.Project{ - Id: uuid.New(), - Name: p.name, - Token: p.token, - Framework: p.framework, - OrganizationId: &org.Id, - CreatedAt: time.Now().UTC(), + Id: uuid.New(), + Name: p.name, + Token: p.token, + Framework: p.framework, + OrganizationId: &org.Id, + CreatedAt: time.Now().UTC(), + AiFlaggedLanguages: models.StringSlice(contentflag.DefaultLanguages), } if p.sourceMapToken != "" { token := p.sourceMapToken diff --git a/docs/pages/client/openrouter/index.mdx b/docs/pages/client/openrouter/index.mdx index ea10a35b..76610490 100644 --- a/docs/pages/client/openrouter/index.mdx +++ b/docs/pages/client/openrouter/index.mdx @@ -57,6 +57,22 @@ Controls what percentage of traces are sent to Traceway. Set to `1` (100%) to ca Optionally filter traces to only include calls made with specific API keys. Useful if you have multiple environments sharing the same OpenRouter organization. +### End-User and Conversation Attribution + +Two request-level fields unlock Traceway's Conversations and Users analytics. Pass them on every chat completion request: + +```json +{ + "model": "anthropic/claude-sonnet-5", + "messages": [...], + "user": "account-8a41", + "session_id": "thread-129af3" +} +``` + +- **`user`** (up to 128 characters) identifies the end user of your product and becomes the `user.id` on the trace. Use a stable customer identifier: your internal account id, a tenant id, or an email. Do not pass session ids or random values here; the same user must carry the same value across all their conversations, or the per-user medians on the Users page become meaningless. If you leave it out, calls attribute to your OpenRouter account itself, which makes every customer look like one user. If PII must stay out of telemetry, use an internal id or a hash. +- **`session_id`** (up to 256 characters, also accepted as the `x-session-id` header) groups requests into one conversation on the Conversations page. Use your chat/thread id, stable for the life of the conversation. + ## What Gets Captured Every OpenRouter completion generates a trace with: @@ -76,7 +92,8 @@ Every OpenRouter completion generates a trace with: | Finish Reason | `gen_ai.response.finish_reason` | `stop` | | Prompt | `gen_ai.prompt` | Full messages JSON | | Completion | `gen_ai.completion` | Full response JSON | -| User ID | `user.id` | Your OpenRouter user/org ID | +| User ID | `user.id` | The `user` field from your request; falls back to your OpenRouter account id | +| Conversation | `gen_ai.conversation.id` / `session.id` | The `session_id` field from your request | | Trace Name | `trace.name` | Workflow identifier | | Temperature | `gen_ai.request.temperature` | `0.7` | diff --git a/docs/pages/learn/ai-tracing.mdx b/docs/pages/learn/ai-tracing.mdx index ecc46c4f..4f4e6ccd 100644 --- a/docs/pages/learn/ai-tracing.mdx +++ b/docs/pages/learn/ai-tracing.mdx @@ -31,6 +31,9 @@ Every AI trace records: | Total Cost | Combined cost | | Finish Reason | Why the model stopped (e.g., `stop`, `length`) | | User ID | The user who triggered the call | +| Conversation ID | Groups multi-turn conversations (see below) | +| Tool Calls | Count and names of tools the model invoked, parsed from the completion | +| Flagged Terms | Content-flag matches found in the prompt or completion at ingest | | Conversation | Full input/output content (stored separately) | ## How It Works @@ -65,6 +68,66 @@ Click a specific call to see the full detail: token breakdown (including cached AI Trace Detail +## Conversations and Users + +Beyond per-call traces, Traceway rolls calls up into conversations and per-user analytics. The AI Traces page has three tabs: **Traces**, **Conversations**, and **Users**. + +### How conversations are identified + +Each AI call gets a conversation id at ingest, resolved in this order: + +1. `gen_ai.conversation.id` span attribute. Set this in your app for reliable multi-turn grouping. +2. `session.id` span or resource attribute. +3. The distributed trace id. A single agent run inside one request still groups its calls, even without an explicit id. + +Calls ingested before this feature (or with none of the above) have no conversation id and are excluded from conversation analytics. + +### Conversations view + +Each row is one conversation: turns (LLM call count), user, tool calls, models used, tokens, cost, and first/last seen. Conversations above the 95th percentile in cost or turns are highlighted so outliers stand out. + +AI Conversations list with flagged badges and outlier highlighting + +Filter by user, model, specific tool, or flagged content. Active filters show as removable pills, and free-text search matches conversation ids, users, models, tool names, and flagged terms, so searching a curse word finds the flagged conversations directly. + +Conversations filtered by tool with a removable filter pill + +Drill into a conversation to read the full chat timeline with tool calls rendered inline (function name, arguments, and paired results), per-turn cost and tokens, and links to each underlying call. + +Conversation detail with the chat timeline and expanded tool call + +### Users view + +Per-customer analytics keyed on the `user.id` attribute: conversation count, total calls, median/average/min conversation length, average cost per conversation, total cost, and flagged conversation count. Clicking a user filters the Conversations view to them. + +#### What to put in `user.id` + +Set `user.id` on every model-call span to a **stable identifier for the end user of your product**, and use the same value across all of that user's sessions and conversations. Good values: your internal account/user id (best), a tenant or organization id for B2B products, or an email if emails are acceptable in your telemetry. Do not use session ids, request ids, or anything random per conversation; that is what `gen_ai.conversation.id` is for, and putting it in `user.id` makes every user look new, which destroys the per-user medians. The value is stored on every AI trace row and is searchable by anyone with project access, so if PII must stay out of telemetry, use an internal id or a hash: the analytics only need stability, not readability. + +With OpenRouter's zero-code Broadcast integration, pass the `user` field in your chat completion requests (same guidance for the value) and it arrives as `user.id`. Without it, calls attribute to your OpenRouter account itself and every customer looks like one user. + +AI Users view with per-customer conversation analytics + +### Content flagging + +At ingest, the prompt and completion text are scanned against built-in profanity language packs plus custom terms. Packs ship for English, German, Spanish, French, Italian, Portuguese, and Serbian; enable any combination, or none to rely on custom terms only. Custom terms cover anything beyond profanity: competitor names, refund phrases, compliance keywords. + +Configure both from the **Flagged terms** button on the Conversations page, or from the project settings sheet (Edit Project, AI tab). Changes take effect for newly ingested calls. + +Flagged term language packs and custom terms in the project AI settings + +Matching is whole-word and case-insensitive, so terms never match inside larger words. Matched terms are indexed on the call at ingestion time only; there is no query-time scanning. They show as a Flagged badge, power the flagged-only filter, and are covered by free-text search, so searching a flagged word finds the conversations containing it. Flagging only applies to calls ingested after the configuration changes; historical calls are not re-scanned. Because matching is word-boundary based, packs are only feasible for languages with space-separated words (no CJK). + +### Alerting on outliers + +Three notification rule types cover AI spend and content: + +| Rule type | Fires when | +|-----------|-----------| +| AI Trace Cost | A single call exceeds a cost threshold | +| AI Conversation Cost | A conversation's cumulative cost over the last 24 hours exceeds a threshold | +| AI Flagged Content | A conversation matches flagged terms (optionally narrowed to specific terms) | + ## Conversation Storage Conversation content (prompts and completions) can be large — a single prompt with RAG context might be 50KB+. To keep the database fast for aggregation queries, Traceway stores conversation content in object storage (S3 or local filesystem) and reads it on demand when you view a specific trace. diff --git a/docs/public/ai-conversation-detail.png b/docs/public/ai-conversation-detail.png new file mode 100644 index 00000000..1c50f2d1 Binary files /dev/null and b/docs/public/ai-conversation-detail.png differ diff --git a/docs/public/ai-conversations-filtered.png b/docs/public/ai-conversations-filtered.png new file mode 100644 index 00000000..b2d0ad92 Binary files /dev/null and b/docs/public/ai-conversations-filtered.png differ diff --git a/docs/public/ai-conversations.png b/docs/public/ai-conversations.png new file mode 100644 index 00000000..118fbb19 Binary files /dev/null and b/docs/public/ai-conversations.png differ diff --git a/docs/public/ai-flagged-terms-settings.png b/docs/public/ai-flagged-terms-settings.png new file mode 100644 index 00000000..b9025855 Binary files /dev/null and b/docs/public/ai-flagged-terms-settings.png differ diff --git a/docs/public/ai-traces-detail.png b/docs/public/ai-traces-detail.png index c5cf6a21..104ce3f0 100644 Binary files a/docs/public/ai-traces-detail.png and b/docs/public/ai-traces-detail.png differ diff --git a/docs/public/ai-traces.png b/docs/public/ai-traces.png index f7f0b6da..e5e72f29 100644 Binary files a/docs/public/ai-traces.png and b/docs/public/ai-traces.png differ diff --git a/docs/public/ai-users.png b/docs/public/ai-users.png new file mode 100644 index 00000000..b963b938 Binary files /dev/null and b/docs/public/ai-users.png differ diff --git a/examples/devtesting-embedded/README.md b/examples/devtesting-embedded/README.md index 7e7c4d63..3d1372c0 100644 --- a/examples/devtesting-embedded/README.md +++ b/examples/devtesting-embedded/README.md @@ -10,8 +10,34 @@ go run . Then: - App: http://localhost:8080/cdn (no build step) or http://localhost:8080 (requires `cd frontend && npm install && npm run build` first) +- AI chat: http://localhost:8080/chat (needs `OPENROUTER_API_KEY` in `.env`) - Dashboard: http://localhost:8082 — login `admin@localhost.com` / `admin` +## AI chat (conversations, tool calls, sub-agents) + +`/chat` is a no-build chat UI backed by OpenRouter, built to exercise the AI conversation analytics end to end. Configuration lives in `.env` (gitignored): + +``` +OPENROUTER_API_KEY=sk-or-... +OPENROUTER_MODEL=anthropic/claude-sonnet-5 +``` + +How it maps to Traceway: + +- Every LLM call emits a `gen_ai.*` span into the `Backend API` project, with real token counts and cost from OpenRouter usage accounting. +- The chat session id becomes `gen_ai.conversation.id`, so each browser conversation shows up on the Conversations tab. The persona picker sets `user.id` for the Users tab. +- The main **Support Chat Agent** has fake tools (`get_weather`, `lookup_order`, `get_server_time`) plus two delegation tools that run sub-agents in the same conversation: **Research Sub-Agent** (with `search_knowledge_base`) and **Math Sub-Agent** (with a real `calculate` evaluator). Each agent is a separate trace name on the AI Traces tab. +- Tool executions run in plain child spans (no `gen_ai.*` attributes) so they appear in the trace waterfall without inflating conversation turn counts; the tool calls themselves are parsed from the completion payloads. + +Suggestion buttons in the UI trigger each tool and sub-agent. To test content flagging, swear at the bot or add custom terms in the project settings AI tab. The API is also curl-able: + +```bash +curl -s http://localhost:8080/api/ai/chat -H 'Content-Type: application/json' -d '{ + "sessionId": "conv-1", "userId": "alice@example.com", + "messages": [{"role": "user", "content": "Weather in Paris, and ask the math agent for 12*(3+4)"}] +}' +``` + ## What runs - **Traceway backend**, embedded on port 8082, SQLite storage in `./storage/`. diff --git a/examples/devtesting-embedded/ai.go b/examples/devtesting-embedded/ai.go new file mode 100644 index 00000000..dd321c1c --- /dev/null +++ b/examples/devtesting-embedded/ai.go @@ -0,0 +1,590 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "hash/fnv" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// An AI chat agent backed by OpenRouter, instrumented with gen_ai.* span +// attributes so every LLM call lands in Traceway's AI Traces / Conversations +// analytics: conversation grouping (gen_ai.conversation.id), per-user stats +// (user.id), tool calls parsed from the completion payload, and sub-agents as +// separate trace names inside the same conversation. + +const openRouterURL = "https://openrouter.ai/api/v1/chat/completions" + +var aiHTTPClient = &http.Client{Timeout: 120 * time.Second} + +// --- OpenRouter wire types (OpenAI chat-completions compatible) --- + +type orToolCall struct { + Id string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type orMessage struct { + Role string `json:"role"` + Content *string `json:"content,omitempty"` + ToolCalls []orToolCall `json:"tool_calls,omitempty"` + ToolCallId string `json:"tool_call_id,omitempty"` +} + +func textMsg(role, content string) orMessage { + return orMessage{Role: role, Content: &content} +} + +type orTool struct { + Type string `json:"type"` + Function orToolDef `json:"function"` +} + +type orToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` +} + +type orRequest struct { + Model string `json:"model"` + Messages []orMessage `json:"messages"` + Tools []orTool `json:"tools,omitempty"` + Usage struct { + Include bool `json:"include"` + } `json:"usage"` +} + +type orChoice struct { + FinishReason string `json:"finish_reason"` + Message orMessage `json:"message"` +} + +type orResponse struct { + Id string `json:"id"` + Model string `json:"model"` + Choices []orChoice `json:"choices"` + Usage struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` + } `json:"usage"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func mustSchema(v string) json.RawMessage { return json.RawMessage(v) } + +// --- Agents --- + +type agentDef struct { + // TraceName becomes trace.name on the gen_ai spans, so each agent shows up + // as its own entry on the AI Traces page while sharing the conversation. + TraceName string + SystemPrompt string + Tools []orTool + // MaxTurns bounds the tool-call loop per user message. + MaxTurns int +} + +var mainAgent = agentDef{ + TraceName: "Support Chat Agent", + SystemPrompt: "You are a helpful support assistant for the Acme web shop. " + + "Use the available tools whenever they can answer the question: weather, order lookups, server time. " + + "Delegate research questions (documentation, product knowledge) to the research agent and " + + "math/calculation questions to the math agent instead of answering from memory. " + + "Keep answers short and conversational.", + Tools: []orTool{ + {Type: "function", Function: orToolDef{ + Name: "get_weather", + Description: "Get the current weather for a city.", + Parameters: mustSchema(`{"type":"object","properties":{"city":{"type":"string","description":"City name"}},"required":["city"]}`), + }}, + {Type: "function", Function: orToolDef{ + Name: "lookup_order", + Description: "Look up the status of a customer order by its order id.", + Parameters: mustSchema(`{"type":"object","properties":{"order_id":{"type":"string","description":"The order id, e.g. A-1042"}},"required":["order_id"]}`), + }}, + {Type: "function", Function: orToolDef{ + Name: "get_server_time", + Description: "Get the current server date and time.", + Parameters: mustSchema(`{"type":"object","properties":{}}`), + }}, + {Type: "function", Function: orToolDef{ + Name: "ask_research_agent", + Description: "Delegate a research or documentation question to the research sub-agent. It has access to the knowledge base.", + Parameters: mustSchema(`{"type":"object","properties":{"question":{"type":"string"}},"required":["question"]}`), + }}, + {Type: "function", Function: orToolDef{ + Name: "ask_math_agent", + Description: "Delegate a math or calculation problem to the math sub-agent. It has a calculator.", + Parameters: mustSchema(`{"type":"object","properties":{"problem":{"type":"string"}},"required":["problem"]}`), + }}, + }, + MaxTurns: 6, +} + +var researchAgent = agentDef{ + TraceName: "Research Sub-Agent", + SystemPrompt: "You are a research sub-agent. Answer the question using the search_knowledge_base tool, " + + "then reply with a concise summary of what you found. Always search before answering.", + Tools: []orTool{ + {Type: "function", Function: orToolDef{ + Name: "search_knowledge_base", + Description: "Search the internal knowledge base for documentation snippets.", + Parameters: mustSchema(`{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}`), + }}, + }, + MaxTurns: 4, +} + +var mathAgent = agentDef{ + TraceName: "Math Sub-Agent", + SystemPrompt: "You are a math sub-agent. Use the calculate tool for any arithmetic instead of computing yourself, " + + "then reply with just the result and one short sentence.", + Tools: []orTool{ + {Type: "function", Function: orToolDef{ + Name: "calculate", + Description: "Evaluate an arithmetic expression with + - * / and parentheses, e.g. \"12*(3+4)\".", + Parameters: mustSchema(`{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}`), + }}, + }, + MaxTurns: 4, +} + +// --- Chat session context threaded through every LLM/tool call --- + +type chatContext struct { + svc *otelService + model string + conversationId string + userId string + events []toolEvent +} + +type toolEvent struct { + Agent string `json:"agent"` + Tool string `json:"tool"` + Args string `json:"args"` + Result string `json:"result"` +} + +// callOpenRouter makes one chat-completions call and emits the gen_ai.* span +// Traceway promotes to an ai_traces row. +func (cc *chatContext) callOpenRouter(ctx context.Context, agent agentDef, messages []orMessage) (*orResponse, error) { + reqBody := orRequest{Model: cc.model, Messages: messages, Tools: agent.Tools} + reqBody.Usage.Include = true + + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + spanCtx, span := cc.svc.tr.Start(ctx, "chat "+cc.model, trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + promptJSON, _ := json.Marshal(map[string]any{"messages": messages}) + span.SetAttributes( + attribute.String("trace.name", agent.TraceName), + attribute.String("gen_ai.conversation.id", cc.conversationId), + attribute.String("user.id", cc.userId), + attribute.String("gen_ai.operation.name", "chat"), + attribute.String("gen_ai.system", "openrouter"), + attribute.String("gen_ai.request.model", cc.model), + attribute.String("gen_ai.prompt", string(promptJSON)), + ) + + httpReq, err := http.NewRequestWithContext(spanCtx, http.MethodPost, openRouterURL, bytes.NewReader(payload)) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+os.Getenv("OPENROUTER_API_KEY")) + httpReq.Header.Set("X-Title", "traceway devtesting-embedded") + + httpResp, err := aiHTTPClient.Do(httpReq) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + + var resp orResponse + if err := json.Unmarshal(body, &resp); err != nil { + span.SetStatus(codes.Error, err.Error()) + return nil, fmt.Errorf("openrouter returned non-JSON (%d): %.300s", httpResp.StatusCode, string(body)) + } + if resp.Error != nil { + span.SetStatus(codes.Error, resp.Error.Message) + return nil, fmt.Errorf("openrouter error: %s", resp.Error.Message) + } + if len(resp.Choices) == 0 { + span.SetStatus(codes.Error, "no choices in response") + return nil, fmt.Errorf("openrouter returned no choices (%d): %.300s", httpResp.StatusCode, string(body)) + } + + completionJSON, _ := json.Marshal(map[string]any{"choices": resp.Choices}) + span.SetAttributes( + attribute.String("gen_ai.response.model", resp.Model), + attribute.Int64("gen_ai.usage.input_tokens", resp.Usage.PromptTokens), + attribute.Int64("gen_ai.usage.output_tokens", resp.Usage.CompletionTokens), + attribute.Int64("gen_ai.usage.total_tokens", resp.Usage.TotalTokens), + attribute.String("gen_ai.response.finish_reason", resp.Choices[0].FinishReason), + attribute.String("gen_ai.completion", string(completionJSON)), + ) + if resp.Usage.Cost > 0 { + span.SetAttributes(attribute.Float64("gen_ai.usage.total_cost", resp.Usage.Cost)) + } + + return &resp, nil +} + +// runAgent drives one agent's tool-call loop and returns its final text reply. +func (cc *chatContext) runAgent(ctx context.Context, agent agentDef, userContent string, history []orMessage) (string, error) { + messages := []orMessage{textMsg("system", agent.SystemPrompt)} + messages = append(messages, history...) + messages = append(messages, textMsg("user", userContent)) + + for turn := 0; turn < agent.MaxTurns; turn++ { + resp, err := cc.callOpenRouter(ctx, agent, messages) + if err != nil { + return "", err + } + + msg := resp.Choices[0].Message + if len(msg.ToolCalls) == 0 { + if msg.Content != nil { + return *msg.Content, nil + } + return "", nil + } + + messages = append(messages, msg) + for _, call := range msg.ToolCalls { + result := cc.executeTool(ctx, agent, call.Function.Name, call.Function.Arguments) + messages = append(messages, orMessage{ + Role: "tool", + Content: &result, + ToolCallId: call.Id, + }) + } + } + return "I hit my tool-call limit before finishing. Please try a simpler question.", nil +} + +// executeTool runs a fake tool (or a sub-agent) inside a plain child span. +// Tool spans deliberately carry no gen_ai.* attributes: the tool calls are +// already captured on the LLM rows via the completion payload, and gen_ai +// attributes here would promote each execution to its own ai_traces row. +func (cc *chatContext) executeTool(ctx context.Context, agent agentDef, name, args string) string { + toolCtx, span := cc.svc.tr.Start(ctx, "tool "+name, trace.WithAttributes( + attribute.String("tool.name", name), + attribute.String("tool.agent", agent.TraceName), + attribute.String("tool.args", truncate(args, 500)), + )) + defer span.End() + + result := cc.dispatchTool(toolCtx, name, args) + span.SetAttributes(attribute.String("tool.result", truncate(result, 500))) + + cc.events = append(cc.events, toolEvent{ + Agent: agent.TraceName, + Tool: name, + Args: truncate(args, 300), + Result: truncate(result, 300), + }) + return result +} + +func (cc *chatContext) dispatchTool(ctx context.Context, name, args string) string { + var params map[string]string + if err := json.Unmarshal([]byte(args), ¶ms); err != nil { + params = map[string]string{} + } + + switch name { + case "get_weather": + return fakeWeather(params["city"]) + case "lookup_order": + return fakeOrderStatus(params["order_id"]) + case "get_server_time": + return time.Now().Format("Monday, 2 Jan 2006 15:04:05 MST") + case "search_knowledge_base": + return fakeKnowledgeBase(params["query"]) + case "calculate": + value, err := evalArithmetic(params["expression"]) + if err != nil { + return "error: " + err.Error() + } + return strconv.FormatFloat(value, 'f', -1, 64) + case "ask_research_agent": + reply, err := cc.runAgent(ctx, researchAgent, params["question"], nil) + if err != nil { + return "research agent failed: " + err.Error() + } + return reply + case "ask_math_agent": + reply, err := cc.runAgent(ctx, mathAgent, params["problem"], nil) + if err != nil { + return "math agent failed: " + err.Error() + } + return reply + default: + return "unknown tool: " + name + } +} + +// --- Fake tool implementations (deterministic per input, no external calls) --- + +func pick(seed string, options []string) string { + h := fnv.New32a() + h.Write([]byte(seed)) + return options[int(h.Sum32())%len(options)] +} + +func fakeWeather(city string) string { + if city == "" { + return "error: city is required" + } + condition := pick(city, []string{"sunny", "partly cloudy", "overcast", "light rain", "thunderstorms", "windy"}) + h := fnv.New32a() + h.Write([]byte(city + "-temp")) + temp := 8 + int(h.Sum32())%22 + return fmt.Sprintf("Weather in %s: %s, %d°C, humidity %d%%.", city, condition, temp, 40+int(h.Sum32())%45) +} + +func fakeOrderStatus(orderId string) string { + if orderId == "" { + return "error: order_id is required" + } + status := pick(orderId, []string{ + "processing in our warehouse", + "shipped and in transit with DHL", + "out for delivery today", + "delivered and signed for", + "delayed at customs, new ETA in 2 days", + }) + h := fnv.New32a() + h.Write([]byte(orderId + "-eta")) + eta := time.Now().AddDate(0, 0, 1+int(h.Sum32())%5).Format("Mon, 2 Jan") + return fmt.Sprintf("Order %s is %s. Estimated delivery: %s.", orderId, status, eta) +} + +func fakeKnowledgeBase(query string) string { + q := strings.ToLower(query) + snippets := []string{} + if strings.Contains(q, "traceway") || strings.Contains(q, "observab") || strings.Contains(q, "trace") { + snippets = append(snippets, "[doc:overview] Traceway is an error tracking and monitoring platform: endpoints, exceptions, logs, metrics, AI traces, and session replay in one self-hostable binary.") + } + if strings.Contains(q, "ai") || strings.Contains(q, "conversation") || strings.Contains(q, "llm") { + snippets = append(snippets, "[doc:ai-tracing] AI calls with gen_ai.* attributes are grouped into conversations via gen_ai.conversation.id; tool calls are parsed from the completion payload and per-user analytics key on user.id.") + } + if strings.Contains(q, "return") || strings.Contains(q, "refund") || strings.Contains(q, "policy") { + snippets = append(snippets, "[doc:returns] Acme shop policy: returns accepted within 30 days of delivery, refunds are processed to the original payment method within 5 business days.") + } + if strings.Contains(q, "shipping") || strings.Contains(q, "delivery") { + snippets = append(snippets, "[doc:shipping] Standard shipping takes 2-5 business days; express is next-day when ordered before 15:00.") + } + if len(snippets) == 0 { + snippets = append(snippets, "[doc:misc] No exact match found. Closest entry: the Acme handbook says to escalate unknown questions to a human agent.") + } + return strings.Join(snippets, "\n") +} + +// evalArithmetic is a tiny recursive-descent evaluator for + - * / and +// parentheses, so the calculate tool actually computes. +func evalArithmetic(input string) (float64, error) { + p := &exprParser{src: strings.ReplaceAll(input, " ", "")} + if p.src == "" { + return 0, fmt.Errorf("empty expression") + } + value, err := p.parseExpr() + if err != nil { + return 0, err + } + if p.pos != len(p.src) { + return 0, fmt.Errorf("unexpected character %q at position %d", p.src[p.pos], p.pos) + } + return value, nil +} + +type exprParser struct { + src string + pos int +} + +func (p *exprParser) parseExpr() (float64, error) { + left, err := p.parseTerm() + if err != nil { + return 0, err + } + for p.pos < len(p.src) && (p.src[p.pos] == '+' || p.src[p.pos] == '-') { + op := p.src[p.pos] + p.pos++ + right, err := p.parseTerm() + if err != nil { + return 0, err + } + if op == '+' { + left += right + } else { + left -= right + } + } + return left, nil +} + +func (p *exprParser) parseTerm() (float64, error) { + left, err := p.parseFactor() + if err != nil { + return 0, err + } + for p.pos < len(p.src) && (p.src[p.pos] == '*' || p.src[p.pos] == '/') { + op := p.src[p.pos] + p.pos++ + right, err := p.parseFactor() + if err != nil { + return 0, err + } + if op == '*' { + left *= right + } else { + if right == 0 { + return 0, fmt.Errorf("division by zero") + } + left /= right + } + } + return left, nil +} + +func (p *exprParser) parseFactor() (float64, error) { + if p.pos < len(p.src) && p.src[p.pos] == '-' { + p.pos++ + value, err := p.parseFactor() + return -value, err + } + if p.pos < len(p.src) && p.src[p.pos] == '(' { + p.pos++ + value, err := p.parseExpr() + if err != nil { + return 0, err + } + if p.pos >= len(p.src) || p.src[p.pos] != ')' { + return 0, fmt.Errorf("missing closing parenthesis") + } + p.pos++ + return value, nil + } + start := p.pos + for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || p.src[p.pos] == '.') { + p.pos++ + } + if start == p.pos { + return 0, fmt.Errorf("expected a number at position %d", start) + } + return strconv.ParseFloat(p.src[start:p.pos], 64) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "…" +} + +// --- HTTP handler --- + +type chatRequest struct { + SessionId string `json:"sessionId"` + UserId string `json:"userId"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` +} + +func registerAIChatRoutes(router *gin.Engine, svc *otelService) { + model := os.Getenv("OPENROUTER_MODEL") + if model == "" { + model = "anthropic/claude-sonnet-5" + } + + router.POST("/api/ai/chat", func(c *gin.Context) { + if os.Getenv("OPENROUTER_API_KEY") == "" { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OPENROUTER_API_KEY is not set; add it to examples/devtesting-embedded/.env"}) + return + } + + var req chatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.SessionId == "" || len(req.Messages) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId and messages are required"}) + return + } + if req.UserId == "" { + req.UserId = "anonymous" + } + last := req.Messages[len(req.Messages)-1] + if last.Role != "user" || strings.TrimSpace(last.Content) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "the last message must be a non-empty user message"}) + return + } + + var history []orMessage + for _, m := range req.Messages[:len(req.Messages)-1] { + if m.Role == "user" || m.Role == "assistant" { + history = append(history, textMsg(m.Role, m.Content)) + } + } + + cc := &chatContext{ + svc: svc, + model: model, + conversationId: req.SessionId, + userId: req.UserId, + } + + reply, err := cc.runAgent(c.Request.Context(), mainAgent, last.Content, history) + if err != nil { + span := trace.SpanFromContext(c.Request.Context()) + span.RecordError(err) + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "reply": reply, + "events": cc.events, + "model": model, + }) + }) +} diff --git a/examples/devtesting-embedded/chat.html b/examples/devtesting-embedded/chat.html new file mode 100644 index 00000000..9cc2ee47 --- /dev/null +++ b/examples/devtesting-embedded/chat.html @@ -0,0 +1,238 @@ + + + + + +AI Chat — devtesting-embedded + + + +
+

AI Chat

+ openrouter + + + + +
+ +
+ + + + + + diff --git a/examples/devtesting-embedded/go.mod b/examples/devtesting-embedded/go.mod index 18397ba5..6f49f26a 100644 --- a/examples/devtesting-embedded/go.mod +++ b/examples/devtesting-embedded/go.mod @@ -5,6 +5,7 @@ go 1.26.2 require ( github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 github.com/tracewayapp/traceway/backend v1.5.2 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 go.opentelemetry.io/otel v1.44.0 @@ -83,7 +84,6 @@ require ( github.com/gorilla/sessions v1.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/examples/devtesting-embedded/main.go b/examples/devtesting-embedded/main.go index c6ba6dd6..90b0768a 100644 --- a/examples/devtesting-embedded/main.go +++ b/examples/devtesting-embedded/main.go @@ -8,11 +8,13 @@ import ( "io/fs" "math/rand/v2" "net/http" + "os" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/joho/godotenv" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -34,6 +36,9 @@ var indexHTML []byte //go:embed cdn.html var cdnHTML []byte +//go:embed chat.html +var chatHTML []byte + //go:embed static/* var staticFS embed.FS @@ -132,6 +137,9 @@ func (s *otelService) log(ctx context.Context, sev otellog.Severity, sevText, bo } func main() { + // OPENROUTER_API_KEY / OPENROUTER_MODEL for the AI chat live in .env. + _ = godotenv.Load() + go tracewaybackend.Run( tracewaybackend.WithSQLitePath("./storage/traceway.db"), tracewaybackend.WithPort(8082), @@ -202,6 +210,12 @@ func main() { c.Data(http.StatusOK, "text/html; charset=utf-8", cdnHTML) }) + router.GET("/chat", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", chatHTML) + }) + + registerAIChatRoutes(router, backendSvc) + staticSub, _ := fs.Sub(staticFS, "static") router.StaticFS("/static", http.FS(staticSub)) @@ -381,8 +395,13 @@ func main() { fmt.Println("=================================================") fmt.Printf(" Node build: http://localhost:%d\n", appPort) fmt.Printf(" CDN (no build): http://localhost:%d/cdn\n", appPort) + fmt.Printf(" AI chat: http://localhost:%d/chat\n", appPort) fmt.Println(" Dashboard: http://localhost:8082") fmt.Println(" Login: admin@localhost.com / admin") + if os.Getenv("OPENROUTER_API_KEY") == "" { + fmt.Println() + fmt.Println(" WARNING: OPENROUTER_API_KEY is not set (.env) — /chat will not work") + } fmt.Println() fmt.Println(" OTel logs test endpoints (hit with curl or browser):") fmt.Printf(" curl http://localhost:%d/api/test-error\n", appPort) diff --git a/frontend/src/lib/components/ai/ai-nav-tabs.svelte b/frontend/src/lib/components/ai/ai-nav-tabs.svelte new file mode 100644 index 00000000..454dfd73 --- /dev/null +++ b/frontend/src/lib/components/ai/ai-nav-tabs.svelte @@ -0,0 +1,33 @@ + + + v && onTabChange(v)}> + + {#each tabs as tab (tab.value)} + {tab.label} + {/each} + + diff --git a/frontend/src/lib/components/ai/conversation-messages.svelte b/frontend/src/lib/components/ai/conversation-messages.svelte new file mode 100644 index 00000000..6f4f3376 --- /dev/null +++ b/frontend/src/lib/components/ai/conversation-messages.svelte @@ -0,0 +1,82 @@ + + +
+ {#each visibleMessages as msg} +
+
+ {getRoleLabel(msg.role).charAt(0)} +
+
+

+ {getRoleLabel(msg.role)} +

+ {#each getMessageImages(msg.content) as imageUrl} + {#if imageUrl && !imageUrl.includes('REDACTED')} +
+ Message attachment +
+ {/if} + {/each} + {#if getMessageText(msg.content)} +
+ {getMessageText(msg.content)} +
+ {/if} + {#each msg.toolCalls ?? [] as call} + + {/each} +
+
+ {/each} +
diff --git a/frontend/src/lib/components/ai/flagged-badge.svelte b/frontend/src/lib/components/ai/flagged-badge.svelte new file mode 100644 index 00000000..b663f37e --- /dev/null +++ b/frontend/src/lib/components/ai/flagged-badge.svelte @@ -0,0 +1,26 @@ + + + + + + + + Flagged + + + +

Matched terms: {terms.join(', ')}

+
+
+
diff --git a/frontend/src/lib/components/ai/flagged-filter.svelte b/frontend/src/lib/components/ai/flagged-filter.svelte new file mode 100644 index 00000000..307b1687 --- /dev/null +++ b/frontend/src/lib/components/ai/flagged-filter.svelte @@ -0,0 +1,30 @@ + + + onChange?.(v ?? 'all')}> + + {label} + + + {#each options as option} + + {option.label} + + {/each} + + diff --git a/frontend/src/lib/components/ai/tool-call-block.svelte b/frontend/src/lib/components/ai/tool-call-block.svelte new file mode 100644 index 00000000..64a72d33 --- /dev/null +++ b/frontend/src/lib/components/ai/tool-call-block.svelte @@ -0,0 +1,73 @@ + + +
+ + {#if showArguments && formattedArguments} +
+

+ Arguments +

+
{formattedArguments}
+
+ {/if} + {#if resultText} +
+ + {#if showResult} +
{resultText}
+ {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/edit-project-modal.svelte b/frontend/src/lib/components/edit-project-modal.svelte index 1548d217..1f07e4cc 100644 --- a/frontend/src/lib/components/edit-project-modal.svelte +++ b/frontend/src/lib/components/edit-project-modal.svelte @@ -2,6 +2,7 @@ import * as Sheet from "$lib/components/ui/sheet"; import * as AlertDialog from "$lib/components/ui/alert-dialog"; import * as Tabs from "$lib/components/ui/tabs"; + import * as Select from "$lib/components/ui/select"; import { Button } from "$lib/components/ui/button"; import { Input } from "$lib/components/ui/input"; import { Label } from "$lib/components/ui/label"; @@ -18,11 +19,12 @@ open: boolean; onOpenChange: (open: boolean) => void; project: Project | null; + initialTab?: string; } - let { open, onOpenChange, project }: Props = $props(); + let { open, onOpenChange, project, initialTab = 'project' }: Props = $props(); - const tabTriggerClass = "-mb-px rounded-none border-b-2 border-transparent bg-transparent px-0 pb-2.5 pt-0 text-sm font-medium text-muted-foreground shadow-none data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"; + const tabTriggerClass = "-mb-px shrink-0 rounded-none border-b-2 border-transparent bg-transparent px-0 pb-2.5 pt-0 text-sm font-medium whitespace-nowrap text-muted-foreground shadow-none data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"; const PROFILE_LABEL_KEY_REGEX = /^[a-zA-Z0-9._:-]+$/; @@ -33,7 +35,28 @@ let healthcheckPathsText = $state(''); let showDefaultHealthcheckPaths = $state(false); let profileLabelsText = $state(''); + let aiFlaggedTermsText = $state(''); + let aiFlaggedLanguages = $state(['en']); let loading = $state(false); + + // Must match contentflag.AvailableLanguages() on the backend. + const FLAGGED_LANGUAGE_OPTIONS = [ + { code: 'en', label: 'English' }, + { code: 'de', label: 'German' }, + { code: 'es', label: 'Spanish' }, + { code: 'fr', label: 'French' }, + { code: 'it', label: 'Italian' }, + { code: 'pt', label: 'Portuguese' }, + { code: 'sr', label: 'Serbian' } + ]; + + const flaggedLanguagesLabel = $derived( + aiFlaggedLanguages.length === 0 + ? 'None (custom terms only)' + : FLAGGED_LANGUAGE_OPTIONS.filter((o) => aiFlaggedLanguages.includes(o.code)) + .map((o) => o.label) + .join(', ') + ); let error = $state(''); let showDeleteConfirm = $state(false); let deleting = $state(false); @@ -42,12 +65,14 @@ $effect(() => { if (open && project) { - activeTab = 'project'; + activeTab = initialTab; projectName = project.name; selectedFramework = project.framework; dropHealthyHealthchecks = project.dropHealthyHealthchecks ?? true; healthcheckPathsText = (project.healthcheckPaths ?? []).join('\n'); profileLabelsText = (project.profileLabelAllowlist ?? []).join('\n'); + aiFlaggedTermsText = (project.aiFlaggedTerms ?? []).join('\n'); + aiFlaggedLanguages = project.aiFlaggedLanguages ?? ['en']; error = ''; } }); @@ -73,6 +98,7 @@ let subtitle = $derived( activeTab === 'healthchecks' ? 'Control which healthcheck requests are stored.' : activeTab === 'profiles' ? 'Choose which pprof sample labels are recorded and searchable.' + : activeTab === 'ai' ? 'Flag AI conversations that contain specific terms.' : activeTab === 'danger' ? 'Irreversible and destructive actions.' : '' ); @@ -101,6 +127,25 @@ return ''; }); + let aiFlaggedTerms = $derived( + aiFlaggedTermsText + .split('\n') + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0) + ); + + let aiFlaggedTermsError = $derived.by(() => { + if (aiFlaggedTerms.length > 200) { + return 'At most 200 flagged terms are allowed.'; + } + for (const term of aiFlaggedTerms) { + if (term.length > 100) { + return 'Flagged terms must be at most 100 characters.'; + } + } + return ''; + }); + async function handleSubmit(e: Event) { e.preventDefault(); if (!projectName.trim()) { @@ -123,7 +168,7 @@ .filter(p => p.length > 0); try { - await projectsState.updateProject(project.id, projectName.trim(), selectedFramework, dropHealthyHealthchecks, healthcheckPaths, profileLabelAllowlist); + await projectsState.updateProject(project.id, projectName.trim(), selectedFramework, dropHealthyHealthchecks, healthcheckPaths, profileLabelAllowlist, aiFlaggedTerms, aiFlaggedLanguages); toast.success('Successfully updated the Project', { position: 'top-center' }); onOpenChange(false); } catch (err: any) { @@ -158,16 +203,17 @@ - + Edit Project { if (v) activeTab = v; }}> - + Project Healthchecks Profiles + AI Danger Zone @@ -297,6 +343,46 @@ Avoid high-cardinality keys like user_id or request_id: every distinct value is stored as a separate sample, which bloats storage and slows queries.

+ {:else if activeTab === 'ai'} +
+ + + + {flaggedLanguagesLabel} + + + {#each FLAGGED_LANGUAGE_OPTIONS as option (option.code)} + + {option.label} + + {/each} + + +

+ Built-in profanity lists to scan conversations with. Deselect all to rely on custom terms only. Matching is per whole word, so these packs only cover languages with space-separated words. +

+
+ +
+ + + {#if aiFlaggedTermsError} +

+ + {aiFlaggedTermsError} +

+ {/if} +

+ One term per line. AI conversations containing these terms, in addition to the selected language packs, are marked as flagged and filterable on the Conversations page. Terms match whole words, case-insensitively, and only apply to calls ingested afterward. +

+
{/if}
diff --git a/frontend/src/lib/components/ui/table-empty-state/table-empty-state.svelte b/frontend/src/lib/components/ui/table-empty-state/table-empty-state.svelte index 1e18fd9a..44302e35 100644 --- a/frontend/src/lib/components/ui/table-empty-state/table-empty-state.svelte +++ b/frontend/src/lib/components/ui/table-empty-state/table-empty-state.svelte @@ -14,7 +14,7 @@ - + {message} diff --git a/frontend/src/lib/state/projects.svelte.ts b/frontend/src/lib/state/projects.svelte.ts index bc04b53f..5e364843 100644 --- a/frontend/src/lib/state/projects.svelte.ts +++ b/frontend/src/lib/state/projects.svelte.ts @@ -81,6 +81,8 @@ export interface Project { dropHealthyHealthchecks: boolean; healthcheckPaths: string[] | null; profileLabelAllowlist: string[] | null; + aiFlaggedTerms: string[] | null; + aiFlaggedLanguages: string[] | null; role?: string; } @@ -175,8 +177,8 @@ class ProjectsState { return response; } - async updateProject(id: string, name: string, framework: Framework, dropHealthyHealthchecks: boolean, healthcheckPaths: string[], profileLabelAllowlist: string[]): Promise { - const response = await api.put('/projects', { name, framework, dropHealthyHealthchecks, healthcheckPaths, profileLabelAllowlist }, { + async updateProject(id: string, name: string, framework: Framework, dropHealthyHealthchecks: boolean, healthcheckPaths: string[], profileLabelAllowlist: string[], aiFlaggedTerms: string[], aiFlaggedLanguages: string[]): Promise { + const response = await api.put('/projects', { name, framework, dropHealthyHealthchecks, healthcheckPaths, profileLabelAllowlist, aiFlaggedTerms, aiFlaggedLanguages }, { projectId: id }); await this.loadProjects(); diff --git a/frontend/src/lib/utils/ai-conversation.ts b/frontend/src/lib/utils/ai-conversation.ts new file mode 100644 index 00000000..9a0b6d57 --- /dev/null +++ b/frontend/src/lib/utils/ai-conversation.ts @@ -0,0 +1,272 @@ +export type ContentPart = { + type: string; + text?: string; + image_url?: { url: string }; +}; + +export type ToolCall = { + id?: string; + name: string; + arguments: string; +}; + +export type ChatMessage = { + role: string; + content: string | ContentPart[]; + toolCalls?: ToolCall[]; + toolCallId?: string; +}; + +export type ConversationTurn = { + id: string; + traceName: string; + recordedAt: string; + duration: number; + model: string; + totalTokens: number; + totalCost: number; + input: string; + output: string; +}; + +export type TimelineEntry = + | { kind: 'message'; message: ChatMessage } + | { kind: 'turn-meta'; turn: ConversationTurn }; + +export function tryParseJson(str: string): any { + try { + return JSON.parse(str); + } catch { + return null; + } +} + +function normalizeOpenAiToolCalls(rawCalls: any): ToolCall[] | undefined { + if (!Array.isArray(rawCalls) || rawCalls.length === 0) return undefined; + const calls: ToolCall[] = []; + for (const call of rawCalls) { + const name = call?.function?.name ?? call?.name; + if (!name) continue; + const args = call?.function?.arguments ?? call?.arguments ?? ''; + calls.push({ + id: call?.id, + name, + arguments: typeof args === 'string' ? args : JSON.stringify(args) + }); + } + return calls.length > 0 ? calls : undefined; +} + +function normalizeInputMessage(msg: any): ChatMessage | null { + if (!msg?.role) return null; + + // Anthropic-style content blocks may carry tool_use / tool_result parts. + if (Array.isArray(msg.content)) { + const toolCalls: ToolCall[] = []; + let toolCallId: string | undefined; + const parts: ContentPart[] = []; + for (const part of msg.content) { + if (part?.type === 'tool_use' && part.name) { + toolCalls.push({ + id: part.id, + name: part.name, + arguments: JSON.stringify(part.input ?? {}) + }); + } else if (part?.type === 'tool_result') { + toolCallId = part.tool_use_id; + const inner = part.content; + if (typeof inner === 'string') { + parts.push({ type: 'text', text: inner }); + } else if (Array.isArray(inner)) { + for (const innerPart of inner) { + if (innerPart?.type === 'text' && innerPart.text) { + parts.push({ type: 'text', text: innerPart.text }); + } + } + } + } else if (part?.type) { + parts.push(part); + } + } + const message: ChatMessage = { + role: toolCallId ? 'tool' : msg.role, + content: parts + }; + if (toolCalls.length > 0) message.toolCalls = toolCalls; + if (toolCallId) message.toolCallId = toolCallId; + return message; + } + + const toolCalls = normalizeOpenAiToolCalls(msg.tool_calls); + if (msg.content === undefined && !toolCalls) return null; + return { + role: msg.role, + content: msg.content ?? '', + toolCalls, + toolCallId: msg.tool_call_id + }; +} + +function extractOutputMessage(output: string): ChatMessage | null { + const outputParsed = tryParseJson(output); + if (!outputParsed) return null; + + // OpenAI-style: {choices: [{message: {role, content, tool_calls}}]} + const choices = outputParsed?.choices; + if (Array.isArray(choices) && choices.length > 0) { + const msg = choices[0]?.message; + if (!msg) return null; + const toolCalls = normalizeOpenAiToolCalls(msg.tool_calls); + if (!msg.content && !toolCalls) return null; + return { role: msg.role || 'assistant', content: msg.content ?? '', toolCalls }; + } + + // Anthropic-style: {content: [{type: "text"|"tool_use", ...}]} + if (Array.isArray(outputParsed?.content)) { + const text = outputParsed.content + .filter((c: any) => c.type === 'text') + .map((c: any) => c.text) + .join(''); + const toolCalls: ToolCall[] = outputParsed.content + .filter((c: any) => c.type === 'tool_use' && c.name) + .map((c: any) => ({ + id: c.id, + name: c.name, + arguments: JSON.stringify(c.input ?? {}) + })); + if (!text && toolCalls.length === 0) return null; + return { + role: outputParsed.role || 'assistant', + content: text, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined + }; + } + + return null; +} + +export function parseInputMessages(input: string): ChatMessage[] | null { + const inputParsed = tryParseJson(input); + if (!inputParsed) return null; + + const inputMessages = inputParsed?.messages ?? (Array.isArray(inputParsed) ? inputParsed : null); + if (!Array.isArray(inputMessages)) return null; + + const messages: ChatMessage[] = []; + for (const msg of inputMessages) { + const normalized = normalizeInputMessage(msg); + if (normalized) messages.push(normalized); + } + return messages; +} + +export function extractMessages(input: string, output: string): ChatMessage[] | null { + const messages = parseInputMessages(input); + if (!messages) return null; + + const outputMessage = extractOutputMessage(output); + if (outputMessage) messages.push(outputMessage); + + return messages.length > 0 ? messages : null; +} + +// pairToolResults maps a tool call id to the tool message that carries its +// result, so ToolCallBlock can render call and result together. Paired tool +// messages should be hidden from the main message list. +export function pairToolResults(messages: ChatMessage[]): Map { + const paired = new Map(); + for (const msg of messages) { + if ((msg.role === 'tool' || msg.role === 'function') && msg.toolCallId && !paired.has(msg.toolCallId)) { + paired.set(msg.toolCallId, msg); + } + } + return paired; +} + +function roleSequenceMatches(previous: ChatMessage[], current: ChatMessage[]): boolean { + if (current.length < previous.length) return false; + for (let i = 0; i < previous.length; i++) { + if (previous[i].role !== current[i].role) return false; + } + return true; +} + +// buildConversationTimeline merges per-call payloads into one chat timeline. +// Most SDKs resend the full history each turn, so each turn's input is +// deduplicated against the previous turn's by role-sequence prefix matching; +// when that fails (compacted histories, unparsable payloads) the caller +// should fall back to per-turn rendering (signaled by returning null). +export function buildConversationTimeline(turns: ConversationTurn[]): TimelineEntry[] | null { + const entries: TimelineEntry[] = []; + let previousInput: ChatMessage[] | null = null; + + for (const turn of turns) { + const inputMessages = parseInputMessages(turn.input); + if (!inputMessages) return null; + + let fresh = inputMessages; + if (previousInput && previousInput.length > 0) { + if (!roleSequenceMatches(previousInput, inputMessages)) return null; + fresh = inputMessages.slice(previousInput.length); + } + for (const message of fresh) { + entries.push({ kind: 'message', message }); + } + + const outputMessage = extractOutputMessage(turn.output); + if (outputMessage) { + entries.push({ kind: 'message', message: outputMessage }); + } + entries.push({ kind: 'turn-meta', turn }); + + previousInput = inputMessages; + if (outputMessage) { + previousInput = [...inputMessages, outputMessage]; + } + } + + return entries.length > 0 ? entries : null; +} + +export function getMessageText(content: string | ContentPart[]): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text!) + .join('\n'); + } + return ''; +} + +export function getMessageImages(content: string | ContentPart[]): string[] { + if (!Array.isArray(content)) return []; + return content + .filter((p) => p.type === 'image_url' && p.image_url?.url) + .map((p) => p.image_url!.url); +} + +export function getRoleLabel(role: string): string { + switch (role) { + case 'system': + return 'System'; + case 'user': + return 'User'; + case 'assistant': + return 'Assistant'; + case 'tool': + return 'Tool'; + case 'function': + return 'Function'; + default: + return role; + } +} + +export function formatConversationContent(raw: string): string { + const parsed = tryParseJson(raw); + if (parsed) { + return JSON.stringify(parsed, null, 2); + } + return raw; +} diff --git a/frontend/src/lib/utils/ai-format.ts b/frontend/src/lib/utils/ai-format.ts new file mode 100644 index 00000000..d9062370 --- /dev/null +++ b/frontend/src/lib/utils/ai-format.ts @@ -0,0 +1,19 @@ +export function formatCost(cost: number): string { + if (cost === 0) return '$0'; + if (cost < 0.001) return `$${cost.toFixed(6)}`; + if (cost < 0.01) return `$${cost.toFixed(4)}`; + if (cost < 1) return `$${cost.toFixed(3)}`; + return `$${cost.toFixed(2)}`; +} + +export function formatCount(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}m`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`; + return count.toLocaleString(); +} + +export function formatTokens(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; + return tokens.toLocaleString(); +} diff --git a/frontend/src/routes/ai-traces/+page.svelte b/frontend/src/routes/ai-traces/+page.svelte index 7d65134e..aad4caed 100644 --- a/frontend/src/routes/ai-traces/+page.svelte +++ b/frontend/src/routes/ai-traces/+page.svelte @@ -18,6 +18,8 @@ import { createRowClickHandler } from '$lib/utils/navigation'; import { resolve } from '$app/paths'; import PageHeader from '$lib/components/issues/page-header.svelte'; + import { formatCost, formatCount, formatTokens } from '$lib/utils/ai-format'; + import AiNavTabs from '$lib/components/ai/ai-nav-tabs.svelte'; import { presetMinutes, getTimeRangeFromPreset, @@ -155,26 +157,6 @@ loadData(false); } - function formatCost(cost: number): string { - if (cost === 0) return '$0'; - if (cost < 0.001) return `$${cost.toFixed(6)}`; - if (cost < 0.01) return `$${cost.toFixed(4)}`; - if (cost < 1) return `$${cost.toFixed(3)}`; - return `$${cost.toFixed(2)}`; - } - - function formatCount(count: number): string { - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}m`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`; - return count.toLocaleString(); - } - - function formatTokens(tokens: number): string { - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; - if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; - return tokens.toLocaleString(); - } - async function loadData(pushToHistory = true) { loading = true; error = ''; @@ -275,6 +257,8 @@
+ + {#each traces as trace} + = 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; - if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; - return tokens.toLocaleString(); - } - async function loadData(pushToHistory = true) { loading = true; error = ''; diff --git a/frontend/src/routes/ai-traces/[traceName]/[traceId]/+page.svelte b/frontend/src/routes/ai-traces/[traceName]/[traceId]/+page.svelte index 6544474d..116f6525 100644 --- a/frontend/src/routes/ai-traces/[traceName]/[traceId]/+page.svelte +++ b/frontend/src/routes/ai-traces/[traceName]/[traceId]/+page.svelte @@ -14,6 +14,11 @@ import { resolve } from '$app/paths'; import TraceLogsPanel from '$lib/components/trace-logs/trace-logs-panel.svelte'; import { traceIdUuidToHex } from '$lib/utils/span-id'; + import { formatCost } from '$lib/utils/ai-format'; + import { extractMessages, formatConversationContent } from '$lib/utils/ai-conversation'; + import ConversationMessages from '$lib/components/ai/conversation-messages.svelte'; + import FlaggedBadge from '$lib/components/ai/flagged-badge.svelte'; + import { addStickyParamsToHref } from '$lib/utils/navigation'; type AiTrace = { id: string; @@ -39,6 +44,11 @@ appVersion: string; attributes: Record | null; distributedTraceId?: string; + conversationId?: string; + toolCallCount?: number; + toolNames?: string[] | null; + flagged?: boolean; + flaggedTerms?: string[] | null; }; type AiTraceDetailResponse = { @@ -58,120 +68,6 @@ let error = $state(''); let notFound = $state(false); - function formatCost(cost: number): string { - if (cost === 0) return '$0'; - if (cost < 0.001) return `$${cost.toFixed(6)}`; - if (cost < 0.01) return `$${cost.toFixed(4)}`; - if (cost < 1) return `$${cost.toFixed(3)}`; - return `$${cost.toFixed(2)}`; - } - - type ChatMessage = { - role: string; - content: string | ContentPart[]; - }; - - type ContentPart = { - type: string; - text?: string; - image_url?: { url: string }; - }; - - function tryParseJson(str: string): any { - try { - return JSON.parse(str); - } catch { - return null; - } - } - - function extractMessages(input: string, output: string): ChatMessage[] | null { - const inputParsed = tryParseJson(input); - if (!inputParsed) return null; - - const messages: ChatMessage[] = []; - - // Extract input messages - could be {messages: [...]} or just [...] - const inputMessages = - inputParsed?.messages ?? (Array.isArray(inputParsed) ? inputParsed : null); - if (!Array.isArray(inputMessages)) return null; - - for (const msg of inputMessages) { - if (msg.role && msg.content !== undefined) { - messages.push({ role: msg.role, content: msg.content }); - } - } - - // Extract assistant response from output - const outputParsed = tryParseJson(output); - if (outputParsed) { - // OpenAI-style: {choices: [{message: {role, content}}]} - const choices = outputParsed?.choices; - if (Array.isArray(choices) && choices.length > 0) { - const choice = choices[0]; - const msg = choice?.message; - if (msg?.content) { - messages.push({ role: msg.role || 'assistant', content: msg.content }); - } - } - // Anthropic-style: {content: [{type: "text", text: "..."}]} - else if (Array.isArray(outputParsed?.content)) { - const text = outputParsed.content - .filter((c: any) => c.type === 'text') - .map((c: any) => c.text) - .join(''); - if (text) { - messages.push({ role: outputParsed.role || 'assistant', content: text }); - } - } - } - - return messages.length > 0 ? messages : null; - } - - function getMessageText(content: string | ContentPart[]): string { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .filter((p) => p.type === 'text' && p.text) - .map((p) => p.text!) - .join('\n'); - } - return ''; - } - - function getMessageImages(content: string | ContentPart[]): string[] { - if (!Array.isArray(content)) return []; - return content - .filter((p) => p.type === 'image_url' && p.image_url?.url) - .map((p) => p.image_url!.url); - } - - function getRoleLabel(role: string): string { - switch (role) { - case 'system': - return 'System'; - case 'user': - return 'User'; - case 'assistant': - return 'Assistant'; - case 'tool': - return 'Tool'; - case 'function': - return 'Function'; - default: - return role; - } - } - - function formatConversationContent(raw: string): string { - const parsed = tryParseJson(raw); - if (parsed) { - return JSON.stringify(parsed, null, 2); - } - return raw; - } - let showRawJson = $state(false); async function loadData() { @@ -251,7 +147,12 @@ - Trace Details +
+ Trace Details + {#if trace.flagged && trace.flaggedTerms?.length} + + {/if} +
Details of this specific AI trace
@@ -285,6 +186,28 @@ {#if trace.userId} {/if} + {#if trace.toolCallCount} + + {/if} + {#if trace.conversationId} +
+

Conversation

+ + {trace.conversationId} + +
+ {/if} @@ -351,52 +274,7 @@ {/if} {:else} -
- {#each chatMessages as msg} -
-
- {getRoleLabel(msg.role).charAt(0)} -
-
-

- {getRoleLabel(msg.role)} -

- {#each getMessageImages(msg.content) as imageUrl} - {#if imageUrl && !imageUrl.includes('REDACTED')} -
- Attached image -
- {/if} - {/each} - {#if getMessageText(msg.content)} -
- {getMessageText(msg.content)} -
- {/if} -
-
- {/each} -
+ {/if}
diff --git a/frontend/src/routes/ai-traces/conversations/+page.svelte b/frontend/src/routes/ai-traces/conversations/+page.svelte new file mode 100644 index 00000000..452fcd20 --- /dev/null +++ b/frontend/src/routes/ai-traces/conversations/+page.svelte @@ -0,0 +1,618 @@ + + +
+ +
+ +
+ +
+
+ +
+ + {#if projectsState.canWriteCurrentProject} + + {/if} +
+ + + + {#snippet children()} + + + + {modelFilterLabel} + + + All models + {#each facetModels as model} + {model} + {/each} + + + + + {toolFilterLabel} + + + All tools + {#each facetTools as tool} + {tool} + {/each} + + + {/snippet} + + + {#if filterPills.length > 0} +
+ {#each filterPills as pill (pill.kind)} + + {pill.label} + = + {pill.value} + + + {/each} +
+ {/if} + + +
+ + {#if loading} + + + +
+ +
+
+
+
+ {:else if error} + + + + {:else if conversations.length === 0} + + + + {:else} + + + + handleSort(field as SortField)} + class="w-[140px]" + /> + handleSort(field as SortField)} + class="w-[90px]" + /> + handleSort(field as SortField)} + class="w-[90px]" + /> + + handleSort(field as SortField)} + class="w-[100px]" + /> + handleSort(field as SortField)} + class="w-[100px]" + /> + handleSort(field as SortField)} + class="w-[130px]" + /> + + + + {#each conversations as conv} + + +
+ {conv.conversationId} + {#if conv.flagged && conv.flaggedTerms?.length} + + {/if} +
+
+ + {conv.userId || '-'} + + + {#if isTurnsOutlier(conv)} + + + + + {formatCount(conv.turns)} + + + + +

Above the 95th percentile turns for this period (P95: {Math.round(thresholds?.p95Turns ?? 0)})

+
+
+
+ {:else} + {formatCount(conv.turns)} + {/if} +
+ + {#if conv.toolNames?.length} + + + + {formatCount(conv.toolCallCount)} + + +

{conv.toolNames.join(', ')}

+
+
+
+ {:else} + {formatCount(conv.toolCallCount)} + {/if} +
+ + {#if conv.models?.length} + + + + + {shortModelName(conv.models[0])} + {#if conv.models.length > 1} + +{conv.models.length - 1} + {/if} + + + +

{conv.models.join(', ')}

+
+
+
+ {:else} + - + {/if} +
+ + {formatTokens(conv.totalTokens)} + + + {#if isCostOutlier(conv)} + + + + + {formatCost(conv.totalCost)} + + + + +

Above the 95th percentile cost for this period (P95: {formatCost(thresholds?.p95Cost ?? 0)})

+
+
+
+ {:else} + {formatCost(conv.totalCost)} + {/if} +
+ + + + + {formatDateTime(conv.lastSeen, { timezone })} + + +

Started {formatDateTime(conv.firstSeen, { timezone })}

+
+
+
+
+
+ {/each} +
+ {/if} +
+
+ + + +
+ + diff --git a/frontend/src/routes/ai-traces/conversations/+page.ts b/frontend/src/routes/ai-traces/conversations/+page.ts new file mode 100644 index 00000000..9f2ce0c4 --- /dev/null +++ b/frontend/src/routes/ai-traces/conversations/+page.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; +import { authState } from '$lib/state/auth.svelte'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + if (!authState.isAuthenticated) { + throw redirect(302, '/login'); + } +}; diff --git a/frontend/src/routes/ai-traces/conversations/[conversationId]/+page.svelte b/frontend/src/routes/ai-traces/conversations/[conversationId]/+page.svelte new file mode 100644 index 00000000..81463273 --- /dev/null +++ b/frontend/src/routes/ai-traces/conversations/[conversationId]/+page.svelte @@ -0,0 +1,304 @@ + + +
+ + + {#if loading} +
+ +
+ {:else if notFound} + + {:else if error} + + {:else if stats} +
+
+

Turns

+

{formatCount(stats.turns)}

+
+
+

Total cost

+

{formatCost(stats.totalCost)}

+
+
+

Total tokens

+

{formatTokens(stats.totalTokens)}

+
+
+

Tool calls

+

{formatCount(stats.toolCallCount)}

+
+
+

User

+ {#if stats.userId} + + {stats.userId} + + {:else} +

-

+ {/if} +
+
+

Duration

+

{conversationDurationLabel(stats)}

+
+
+ + + +
+
+ Conversation + {#if stats.flagged && stats.flaggedTerms?.length} + + {/if} +
+ + {formatDateTime(stats.firstSeen, { timezone })} to {formatDateTime(stats.lastSeen, { timezone })} + +
+ +
+ + {#if showRawJson} +
+ {#each turns as turn, i} +
+

+ Turn {i + 1} · {formatDateTime(turn.recordedAt, { timezone })} +

+ {#if turn.input} +
+
{formatConversationContent(turn.input)}
+
+ {/if} + {#if turn.output} +
+
{formatConversationContent(turn.output)}
+
+ {/if} +
+ {/each} +
+ {:else if timeline} +
+ {#each timeline as entry, i} + {#if entry.kind === 'message'} + + {:else} + {@const turnNumber = timeline.slice(0, i + 1).filter((e) => e.kind === 'turn-meta').length} +
+
+ + Turn {turnNumber} + {#if entry.turn.model} + · {entry.turn.model} + {/if} + · {formatCost(entry.turn.totalCost)} + · {formatTokens(entry.turn.totalTokens)} tokens + · {formatDuration(entry.turn.duration)} + · + e.stopPropagation()} + > + View call → + + +
+
+ {/if} + {/each} +
+ {:else} + +
+ {#each turns as turn, i} + {@const messages = extractMessages(turn.input, turn.output)} +
+
+ Turn {i + 1} + {#if turn.model} + · {turn.model} + {/if} + · {formatCost(turn.totalCost)} + · {formatTokens(turn.totalTokens)} tokens + · {formatDuration(turn.duration)} + · {formatDateTime(turn.recordedAt, { timezone })} + · + View call → +
+ {#if messages} + + {:else} +

+ No conversation payload was captured for this turn. +

+ {/if} +
+ {/each} +
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/ai-traces/conversations/[conversationId]/+page.ts b/frontend/src/routes/ai-traces/conversations/[conversationId]/+page.ts new file mode 100644 index 00000000..b6993b39 --- /dev/null +++ b/frontend/src/routes/ai-traces/conversations/[conversationId]/+page.ts @@ -0,0 +1,15 @@ +import { redirect } from '@sveltejs/kit'; +import { authState } from '$lib/state/auth.svelte'; +import type { PageLoad } from './$types'; + +export const prerender = false; + +export const load: PageLoad = ({ params }) => { + if (!authState.isAuthenticated) { + throw redirect(302, '/login'); + } + + return { + conversationId: params.conversationId + }; +}; diff --git a/frontend/src/routes/ai-traces/users/+page.svelte b/frontend/src/routes/ai-traces/users/+page.svelte new file mode 100644 index 00000000..062597b7 --- /dev/null +++ b/frontend/src/routes/ai-traces/users/+page.svelte @@ -0,0 +1,416 @@ + + +
+ +
+ +
+ +
+
+ + + + + + + +
+ + {#if loading} + + + +
+ +
+
+
+
+ {:else if error} + + + + {:else if users.length === 0} + + + + {:else} + + + + handleSort(field as SortField)} + class="w-[130px]" + /> + handleSort(field as SortField)} + class="w-[90px]" + /> + handleSort(field as SortField)} + class="w-[130px]" + /> + handleSort(field as SortField)} + class="w-[130px]" + /> + handleSort(field as SortField)} + class="w-[110px]" + /> + handleSort(field as SortField)} + class="w-[100px]" + /> + handleSort(field as SortField)} + class="w-[130px]" + /> + + + + {#each users as user} + + + {user.userId} + + + {formatCount(user.conversationCount)} + + + {formatCount(user.totalCalls)} + + + + + + {user.medianTurns.toFixed(1)} + + +

avg {user.avgTurns.toFixed(1)} · min {user.minTurns}

+
+
+
+
+ + {formatCost(user.avgCostPerConversation)} + + + {formatCost(user.totalCost)} + + + {#if user.flaggedConversationCount > 0} + + + {formatCount(user.flaggedConversationCount)} + + {:else} + 0 + {/if} + + + {formatDateTime(user.lastSeen, { timezone })} + +
+ {/each} +
+ {/if} +
+
+ + + +
diff --git a/frontend/src/routes/ai-traces/users/+page.ts b/frontend/src/routes/ai-traces/users/+page.ts new file mode 100644 index 00000000..9f2ce0c4 --- /dev/null +++ b/frontend/src/routes/ai-traces/users/+page.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; +import { authState } from '$lib/state/auth.svelte'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + if (!authState.isAuthenticated) { + throw redirect(302, '/login'); + } +}; diff --git a/frontend/src/routes/notifications/rule-dialog.svelte b/frontend/src/routes/notifications/rule-dialog.svelte index c8f165c6..89faf553 100644 --- a/frontend/src/routes/notifications/rule-dialog.svelte +++ b/frontend/src/routes/notifications/rule-dialog.svelte @@ -82,6 +82,8 @@ let ignorePatterns = $state(''); let traceName = $state('*'); let thresholdCost = $state(0.5); + let conversationThresholdCost = $state(5); + let flaggedTermsFilter = $state(''); const isEditing = $derived(rule !== null); @@ -111,6 +113,10 @@ 'Fires when an endpoint\u2019s impact score reaches medium level (\u2265 0.25), an early warning of emerging performance or reliability issues.', ai_trace_cost: 'Fires when an individual AI trace exceeds a cost threshold. Monitors per-call spending across AI providers.', + ai_conversation_cost: + 'Fires when the cumulative cost of a single AI conversation (over the last 24 hours) exceeds the threshold. Catches runaway agent loops and expensive sessions.', + ai_flagged_content: + 'Fires when an AI conversation matches a flagged content term. Uses the built-in profanity list plus custom terms from the project’s AI settings; optionally narrow to specific terms below.', error_regression: 'Fires when an error that was previously archived (resolved) occurs again.', error_rate_threshold: @@ -266,6 +272,12 @@ traceName = cfg.traceName ?? '*'; thresholdCost = cfg.thresholdCost ?? 0.5; break; + case 'ai_conversation_cost': + conversationThresholdCost = cfg.thresholdCost ?? 5; + break; + case 'ai_flagged_content': + flaggedTermsFilter = (cfg.terms || []).join(', '); + break; } } @@ -305,6 +317,15 @@ return { minRequests }; case 'ai_trace_cost': return { traceName, thresholdCost }; + case 'ai_conversation_cost': + return { thresholdCost: conversationThresholdCost }; + case 'ai_flagged_content': { + const terms = flaggedTermsFilter + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter((t) => t); + return { terms }; + } default: return {}; } @@ -770,6 +791,29 @@ min="0" /> + {:else if ruleType === 'ai_conversation_cost'} +
+ + +
+ {:else if ruleType === 'ai_flagged_content'} +
+ + +

+ Custom flagged terms are managed in the project settings AI tab. +

+
{/if} diff --git a/frontend/src/routes/notifications/rule-types.ts b/frontend/src/routes/notifications/rule-types.ts index cf720bfa..936a6a45 100644 --- a/frontend/src/routes/notifications/rule-types.ts +++ b/frontend/src/routes/notifications/rule-types.ts @@ -15,7 +15,9 @@ export const ruleTypeOptions = [ { value: 'task_duration_threshold', label: 'Task Duration' }, { value: 'task_failure_rate', label: 'Task Failure Rate' }, { value: 'throughput_drop', label: 'Throughput Drop' }, - { value: 'ai_trace_cost', label: 'AI Trace Cost' } + { value: 'ai_trace_cost', label: 'AI Trace Cost' }, + { value: 'ai_conversation_cost', label: 'AI Conversation Cost' }, + { value: 'ai_flagged_content', label: 'AI Flagged Content' } ]; export const ruleTypeLabels: Record = Object.fromEntries( diff --git a/skills/traceway-setup/SKILL.md b/skills/traceway-setup/SKILL.md index 5d6fe25f..11807088 100644 --- a/skills/traceway-setup/SKILL.md +++ b/skills/traceway-setup/SKILL.md @@ -1,6 +1,6 @@ --- name: traceway-setup -description: Connect a project to a Traceway instance so it reports endpoints, spans, errors, background tasks, AI traces, and metrics. Backends use OpenTelemetry over OTLP/HTTP, frontends and mobile apps (including native iOS/Swift) use the Traceway SDKs, and host metrics use the Traceway OTel Agent. Use when the user wants to add Traceway (or OpenTelemetry tracing that exports to Traceway) to a backend, frontend, full-stack, mobile, or iOS/Swift project. Accepts a project token and instance URL, e.g. "/traceway-setup with token abc123". +description: Connect a project to a Traceway instance so it reports endpoints, spans, errors, background tasks, AI traces, and metrics. Backends use OpenTelemetry over OTLP/HTTP, frontends and mobile apps (including native iOS/Swift) use the Traceway SDKs, and host metrics use the Traceway OTel Agent. AI agents and chatbots additionally get conversation grouping, per-user analytics, and tool-call instrumentation (see ai-agent.md). Use when the user wants to add Traceway (or OpenTelemetry tracing that exports to Traceway) to a backend, frontend, full-stack, mobile, iOS/Swift, or AI agent/chatbot project. Accepts a project token and instance URL, e.g. "/traceway-setup with token abc123". --- # Set Up Traceway in a Project @@ -70,7 +70,7 @@ Before changing anything, build a picture of what needs instrumenting: Frontend-only means integrate ONLY the browser side with the frontend SDK (React for Next.js); do not add OTel or instrument the framework's server side. The separate backend is its own part with its own Traceway project and its own Step 2 integration. 3. **Services and entry points**: in a monorepo, list each deployable service and its entry point. Each part that should report to Traceway gets its own integration and its own project token, per the one-project-per-part rule in Step 0; ask the user for the missing tokens instead of reusing one across parts. 4. **Background work**: find cron jobs, queue consumers, schedulers, CLI commands, and long-running workers. These must be instrumented as Tasks (Step 3). -5. **AI/LLM usage**: check dependencies for `openai`, `@anthropic-ai/sdk`, `anthropic`, `langchain` / `@langchain/*`, `ai` (Vercel AI SDK), `litellm`, `google-generativeai`, `cohere`, `openrouter`. If any are present, Step 4 applies. +5. **AI/LLM usage**: check dependencies for `openai`, `@anthropic-ai/sdk`, `anthropic`, `langchain` / `@langchain/*`, `ai` (Vercel AI SDK), `litellm`, `google-generativeai`, `cohere`, `openrouter`, and agent frameworks (`langgraph`, `crewai`, `autogen` / `ag2`, `pydantic-ai`, `@openai/agents`, `@mastra/*`, `llamaindex` / `llama-index`, `semantic-kernel`). If any are present, Step 4 applies. Then determine whether this is a **conversational product** (chatbot, assistant, agent) rather than one-shot LLM calls: look for chat endpoints or streaming chat routes (`/chat`, `/completions`, SSE or websocket handlers feeding an LLM), message-history persistence (a `messages`/`conversations` table or thread ids), tool/function-calling definitions passed to the model, or any agent framework above. If conversational, `ai-agent.md` in this skill directory governs Step 4 — it adds conversation grouping, per-user attribution, tool calls, and sub-agents. 6. **Deployment signals**: note Dockerfiles, `docker-compose.yml`, Kubernetes manifests, Helm charts, deploy/provisioning scripts, `fly.toml`, `vercel.json`, Procfiles. You will use these in Step 5 to set up server metrics. ## Step 2: Backend OTel Setup @@ -206,7 +206,9 @@ async function runScheduledJob() { If Step 1 found AI/LLM dependencies, instrument the model calls. Any span carrying at least one `gen_ai.*` attribute is promoted to an **AI Trace**; since calls usually happen inside a request or task, the span is naturally a child and stays linked to its Endpoint/Task by trace ID. -Boundaries: **one span per model call** (one provider API request = one AI Trace row). A multi-step agent run is multiple spans sharing a stable `trace.name` (same labeling discipline as task names: no IDs or timestamps). For streaming, end the span when the stream finishes. Set `user.id` to break down usage and cost per user. +Boundaries: **one span per model call** (one provider API request = one AI Trace row). A multi-step agent run is multiple spans sharing a stable `trace.name` (same labeling discipline as task names: no IDs or timestamps). For streaming, end the span when the stream finishes. + +**Conversational product?** If Step 1 flagged a chatbot, assistant, or tool-calling agent, follow `ai-agent.md` in this skill directory instead of stopping at the table below — it covers `gen_ai.conversation.id` (multi-turn grouping), what value belongs in `user.id` (a stable end-customer id, never a session id), tool-call payload shapes, and sub-agents. Without those, the Conversations and Users tabs stay empty. Attributes Traceway reads (all optional, set what is available): @@ -219,7 +221,8 @@ Attributes Traceway reads (all optional, set what is available): | `gen_ai.usage.input_tokens.cached` / `gen_ai.usage.output_tokens.reasoning` | Cached / reasoning tokens | | `gen_ai.usage.input_cost` / `.output_cost` / `.total_cost` | Cost, when you compute pricing | | `trace.name` | Agent/workflow grouping name | -| `user.id` | End-user attribution | +| `gen_ai.conversation.id` | Multi-turn conversation grouping (falls back to `session.id`, then the OTel trace id) | +| `user.id` | End-user attribution: a stable customer id (account id, tenant id, or email), the same value across all of that user's conversations | | `gen_ai.response.finish_reason` | Why generation stopped | | `gen_ai.prompt` / `gen_ai.completion` | Conversation content, shown on the trace detail page (skip if content must not leave the app) | diff --git a/skills/traceway-setup/ai-agent.md b/skills/traceway-setup/ai-agent.md new file mode 100644 index 00000000..fdb67c43 --- /dev/null +++ b/skills/traceway-setup/ai-agent.md @@ -0,0 +1,108 @@ +# Instrumenting an AI Agent or Chatbot for Traceway + +Read this file when Step 1 detected a conversational AI product: a chatbot, a multi-turn assistant, a tool-calling agent, or anything built on an agent framework. The base rules from SKILL.md Step 4 still apply (one span per model call, `gen_ai.*` attributes promote the span to an AI Trace). This file adds what conversational products need on top, so Traceway's Conversations and Users analytics light up: conversation grouping, per-user attribution, tool calls, and sub-agents. + +## What you get when this is wired correctly + +The AI Traces section of the dashboard has three tabs. Per-call spans alone only populate the first: + +| Tab | Needs | Shows | +|---|---|---| +| **Traces** | any `gen_ai.*` span | per-agent call counts, latency, tokens, cost | +| **Conversations** | `gen_ai.conversation.id` | turns, cost, tokens, tool calls per conversation; flagged-content badges; P95 outlier highlighting; tool/model/user filters | +| **Users** | `gen_ai.conversation.id` + `user.id` | per-customer analytics: conversation count, median/avg/min conversation length, cost per conversation, flagged conversations | + +## Conversation identity + +Traceway resolves a conversation id per call, in this order: + +1. `gen_ai.conversation.id` span attribute — set this explicitly; it is the reliable path. +2. `session.id` span or resource attribute. +3. The OTel trace id — a single agent run inside one request groups automatically, but turns arriving in later requests will NOT join it. + +Use the id your app already has for the chat session: the chat/thread id from your database, or the session id the frontend holds. Stable for the life of the conversation, different between conversations. Every model call in the agent loop (including retries and sub-agent calls) must carry the same value. + +## `user.id`: what to set and why it matters + +`user.id` is the customer dimension for the Users tab and the user filter on Conversations. Set it on every model-call span. + +**Good values** — a stable identifier for the end user of YOUR product: + +- your internal account/user id (best), +- the tenant or organization id for B2B products where the account is the unit you care about, +- an email address, if emails are acceptable in your telemetry. + +**Bad values:** + +- session ids, request ids, or anything random per conversation — that is `gen_ai.conversation.id`'s job; using it here makes every user look like a new user and destroys the per-customer medians, +- device ids that churn across reinstalls, +- leaving it unset — the Users tab stays empty and flagged conversations cannot be attributed. + +**Privacy**: the value is stored on every AI trace row and is searchable and filterable by anyone with project access. If PII must not enter telemetry, use your internal id or a hash of it — the analytics only need stability, not readability. + +## Tool calls + +Traceway parses tool calls out of the completion payload (`gen_ai.completion`), so the shape matters. Any of these work: + +- OpenAI chat completions: `{"choices":[{"message":{"tool_calls":[{"function":{"name":...,"arguments":...}}]}}]}` +- Anthropic messages: `{"content":[{"type":"tool_use","name":...,"input":...}]}` +- OTel GenAI output messages: `[{"role":"assistant","parts":[{"type":"tool_call","name":...}]}]` + +Set `gen_ai.completion` to the raw provider response (or the shapes above) and tool names/counts appear on the conversation rows, become filterable, and render in the chat view with arguments and results. + +Two rules for the tool executions themselves: + +1. **Wrap each tool execution in a plain child span** (name it `tool `, attributes like `tool.name`, `tool.args`) so it shows in the trace waterfall. +2. **Do NOT put `gen_ai.*` attributes on tool-execution spans.** Any `gen_ai.*` attribute promotes the span to its own AI Trace row, which inflates the conversation's turn count. The single exception is a span that represents a standalone tool-execution step in the OTel semconv style (`gen_ai.operation.name = "execute_tool"` + `gen_ai.tool.name`) — Traceway counts that as a tool call, but the completion-payload path above is preferred. + +## Sub-agents + +When the main agent delegates to sub-agents (a researcher, a planner, a summarizer), give each sub-agent's model calls their own `trace.name` ("Research Sub-Agent") while keeping the SAME `gen_ai.conversation.id` and `user.id`. Each agent then appears separately on the Traces tab with its own latency/cost profile, while the Conversations tab shows the whole delegation as one conversation, with the sub-agent turns interleaved in the chat timeline. + +## Putting it together (agent loop skeleton) + +```typescript +async function agentTurn(conversationId: string, userId: string, messages: Message[]) { + return tracer.startActiveSpan(`chat ${MODEL}`, { kind: SpanKind.CLIENT }, async (span) => { + const response = await llm.chat({ model: MODEL, messages, tools }); + span.setAttributes({ + "trace.name": "Support Chat Agent", // sub-agents use their own name here + "gen_ai.conversation.id": conversationId, // same for every call in the conversation + "user.id": userId, // stable end-customer id + "gen_ai.system": "openai", + "gen_ai.request.model": MODEL, + "gen_ai.response.model": response.model, + "gen_ai.usage.input_tokens": response.usage.prompt_tokens, + "gen_ai.usage.output_tokens": response.usage.completion_tokens, + "gen_ai.response.finish_reason": response.choices[0].finish_reason, + "gen_ai.prompt": JSON.stringify({ messages }), + "gen_ai.completion": JSON.stringify({ choices: response.choices }), + }); + span.end(); + return response; + }); +} +``` + +Each iteration of the tool-call loop is its own span/call — that is what "turns" counts. Resending the full message history in `gen_ai.prompt` each turn is expected; the conversation view deduplicates it. + +## OpenRouter zero-code path + +If the app calls OpenRouter and uses the Broadcast/Observability feature instead of its own spans, the same analytics work through request-level fields: + +- Pass `user` in the request body (up to 128 chars) — the end-customer id, same guidance as `user.id` above. Without it, calls attribute to the OpenRouter account itself, which makes the Users tab useless. +- Pass `session_id` in the request body (or the `x-session-id` header) for conversation grouping. +- Optional `trace` metadata (`trace_name`) names the agent. + +## Content flagging (no code required) + +Prompt and completion text is scanned at ingestion against per-project profanity language packs (en/de/es/fr/it/pt/sr) plus custom terms. Configure from the Conversations page's **Flagged terms** button. Nothing to instrument — it works as soon as `gen_ai.prompt`/`gen_ai.completion` are populated. Mention this to the user; custom terms (competitor names, compliance phrases) are usually worth setting up on day one. + +## Verify + +After wiring, run one real multi-turn chat with a tool call, then check in the dashboard (or via the API): + +1. **AI Traces -> Conversations**: the conversation appears, turn count matches the number of model calls, tool names are listed. +2. **AI Traces -> Users**: the test user appears with 1 conversation. +3. Open the conversation: the chat timeline renders with tool calls inline; each turn links to its call. +4. If any of these are empty, the corresponding attribute is missing — conversation id (tab 1 empty), user id (tab 2 empty), or completion payload shape (tool columns empty). diff --git a/website/app/product/ai-tracing/page.tsx b/website/app/product/ai-tracing/page.tsx index 32872660..9fe28a33 100644 --- a/website/app/product/ai-tracing/page.tsx +++ b/website/app/product/ai-tracing/page.tsx @@ -24,9 +24,11 @@ export default function AiTracingPage() { See every AI call, its cost, and its conversation.

- Monitor LLM costs, token usage, latency, and conversations across - every provider. Works with OpenRouter, OpenAI, Anthropic, and any - OpenTelemetry-compatible provider. + Monitor LLM costs, token usage, latency, conversations, and tool + calls across every provider. Group calls into conversations, break + them down per user, and flag the ones that need attention. Works + with OpenRouter, OpenAI, Anthropic, and any OpenTelemetry-compatible + provider.

@@ -66,15 +68,53 @@ export default function AiTracingPage() { + + + {/* Conversations & users */} +
+ + Find the outliers, per conversation and per user + + } + description="Calls group into conversations automatically. See turns, cost, tokens, and tool usage per conversation, and roll it all up per user: how long conversations run, what they cost, and who your heaviest users are. Anything above the 95th percentile is highlighted." + bullets={[ + "Turns, cost, tokens, and tool calls per conversation", + "Per-user analytics: median conversation length, cost per conversation", + "Filter by user, model, or specific tool with removable filter pills", + "P95 outlier highlighting on cost and conversation length", + ]} + image={{ src: "/images/ai-conversations.png", alt: "AI conversations analytics" }} + /> +
+ + {/* Content flags */} +
+
@@ -173,6 +213,34 @@ export default function AiTracingPage() { q: "How does this work with OpenRouter?", a: "OpenRouter has a built-in Observability feature that broadcasts OTLP traces for every LLM call. You add Traceway as an 'OpenTelemetry Collector' destination in your OpenRouter settings with your Traceway endpoint and project token. No code changes needed.", }, + { + q: "How does per-user cost and conversation tracking work?", + a: ( + <> +

+ Traceway keys per-customer analytics on the{" "} + user.id span attribute (with OpenRouter, + just pass the user field in your requests). + Set it to a stable identifier for the end user of your + product: your internal account id, a tenant id, or an + email. The same user must carry the same value across + all their conversations. +

+

+ Never put session ids or random values there — that is + what the conversation id is for. With a stable id, the + Users view shows conversation count, median conversation + length, and cost per conversation for every customer. + If PII must stay out of telemetry, use an internal id or + a hash; the analytics only need stability. +

+ + ), + }, + { + q: "Can I find conversations containing specific words, like profanity?", + a: "Yes. Traceway scans every prompt and completion at ingestion against built-in profanity lists (seven languages) plus custom terms you configure per project. Matches are stored on the call, so flagged conversations are instantly filterable and searchable, and an alert rule can notify you the moment flagged content appears. Because scanning happens at ingest, there is no expensive query-time content search.", + }, { q: "Can I track costs across multiple models?", a: ( diff --git a/website/components/home-tabs.tsx b/website/components/home-tabs.tsx index 8664a396..3a4659b1 100644 --- a/website/components/home-tabs.tsx +++ b/website/components/home-tabs.tsx @@ -73,7 +73,7 @@ const tabs: Tab[] = [ color: "var(--a3)", heading: "Track every AI call, its cost, and its conversation.", description: - "Monitor LLM costs, token usage, and latency across every provider. See the full prompt and completion for every call, with per-agent and per-model breakdowns.", + "Monitor LLM costs, token usage, and latency across every provider. Group calls into conversations with tool calls rendered inline, break costs down per user, and flag conversations containing terms you care about.", bullets: [ "Per-call cost and token tracking", "Conversation replay with chat view", diff --git a/website/public/images/ai-conversation-detail.png b/website/public/images/ai-conversation-detail.png new file mode 100644 index 00000000..1c50f2d1 Binary files /dev/null and b/website/public/images/ai-conversation-detail.png differ diff --git a/website/public/images/ai-conversations.png b/website/public/images/ai-conversations.png new file mode 100644 index 00000000..118fbb19 Binary files /dev/null and b/website/public/images/ai-conversations.png differ diff --git a/website/public/images/ai-flagged-terms.png b/website/public/images/ai-flagged-terms.png new file mode 100644 index 00000000..b9025855 Binary files /dev/null and b/website/public/images/ai-flagged-terms.png differ