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
+## 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.
+
+
+
+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.
+
+
+
+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.
+
+
+
+### 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.
+
+
+
+### 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.
+
+
+
+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 @@
+
+
+
+ {getRoleLabel(msg.role)} +
+ {#each getMessageImages(msg.content) as imageUrl} + {#if imageUrl && !imageUrl.includes('REDACTED')} +Matched terms: {terms.join(', ')}
++ Arguments +
+{formattedArguments}
+ {resultText}
+ {/if}
+ user_id or request_id: every distinct value is stored as a separate sample, which bloats storage and slows queries.
+ {:else if activeTab === 'ai'}
+ + 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. +
+
+
+ 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. +
+Conversation
+ + {trace.conversationId} + +- {getRoleLabel(msg.role)} -
- {#each getMessageImages(msg.content) as imageUrl} - {#if imageUrl && !imageUrl.includes('REDACTED')} -Above the 95th percentile turns for this period (P95: {Math.round(thresholds?.p95Turns ?? 0)})
+{conv.toolNames.join(', ')}
+{conv.models.join(', ')}
+Above the 95th percentile cost for this period (P95: {formatCost(thresholds?.p95Cost ?? 0)})
+Started {formatDateTime(conv.firstSeen, { timezone })}
+Turns
+{formatCount(stats.turns)}
+Total cost
+{formatCost(stats.totalCost)}
+Total tokens
+{formatTokens(stats.totalTokens)}
+Tool calls
+{formatCount(stats.toolCallCount)}
+Duration
+{conversationDurationLabel(stats)}
++ Turn {i + 1} · {formatDateTime(turn.recordedAt, { timezone })} +
+ {#if turn.input} +{formatConversationContent(turn.input)}
+ {formatConversationContent(turn.output)}
+ + No conversation payload was captured for this turn. +
+ {/if} +avg {user.avgTurns.toFixed(1)} · min {user.minTurns}
++ Custom flagged terms are managed in the project settings AI tab. +
+- 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.
+ 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