Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4d58ff3
Migration to Bifrost (#484)
crspeller Feb 19, 2026
eff5b3e
Merge origin/master into agents-v2
nickmisasi Mar 3, 2026
bc165b0
Remove superseded EnableLLMTrace tool tracing and dead LLMetrics code
cursoragent Mar 5, 2026
38d4da8
Add telemetry package with OTel tracing initialization
cursoragent Mar 5, 2026
0d0290f
Add context.Context parameter to LanguageModel interface call sites
cursoragent Mar 5, 2026
5e70558
Thread context.Context from entry points through full request pipeline
cursoragent Mar 5, 2026
8b0c119
Add otelgin HTTP middleware and LLM call span instrumentation
cursoragent Mar 5, 2026
5a5c2ad
Add span instrumentation to tool execution, search, MCP, and streaming
cursoragent Mar 5, 2026
64533df
Add telemetry unit tests and fix resource schema URL conflict
cursoragent Mar 5, 2026
e301347
Add Jaeger docker-compose for local trace visualization
cursoragent Mar 5, 2026
0e6db8b
Add integration tests for OTel span pipeline and record tool errors
cursoragent Mar 6, 2026
dec0fb3
Add OpenTelemetry documentation for agents, admins, and developers
cursoragent Mar 6, 2026
a7148f4
Fix lint: remove ineffectual ctx assignment and fix gofmt spacing
cursoragent Mar 6, 2026
9ce87c3
Merge origin/master into opentelemetry branch
cursoragent Mar 25, 2026
b135afd
Merge origin/master and update new code for OTel compatibility
cursoragent Mar 27, 2026
069b8b0
Merge origin/master: module rename and new features
cursoragent Apr 13, 2026
92ee281
Add OpenTelemetry controls to System Console and remove EnableLLMTrace
cursoragent Apr 13, 2026
81b3f6e
Fix missing ctx in mcpserver eval test helpers
cursoragent Apr 13, 2026
85178c0
Remove unused testTraceLog type from mcpserver eval helpers
cursoragent Apr 13, 2026
964405d
Merge origin/master into opentelemetry-tracing branch
crspeller May 4, 2026
e17a6f3
Bridge Bifrost internal tracer into OpenTelemetry
crspeller May 5, 2026
7d5b31d
Replace Jaeger dev stack with Grafana Tempo
crspeller May 5, 2026
c2beb63
Use single context import alias in conversation test
crspeller May 5, 2026
efc1944
Address review feedback on async context cancellation and dep alignment
crspeller May 5, 2026
fb445a9
Bound async-span test wait with timeout
crspeller May 5, 2026
091913e
Anchor agent traces to user turn ID for HA cross-node correlation
crspeller May 6, 2026
e6ae10d
Rename Bot attribute keys to Agent and add thread root post ID
crspeller May 6, 2026
0336d4e
Rename OTel attribute namespace from ai. to agents.
crspeller May 6, 2026
3ce211f
Add tri-state telemetry output mode and live config reload
crspeller May 6, 2026
04bafa5
Increase tempo block retention to 48h
crspeller May 7, 2026
be8d3c6
Merge remote-tracking branch 'origin/master' into cursor/opentelemetr…
crspeller May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,22 @@
- Run evals with multiple providers: `LLM_PROVIDER=openai,anthropic make evals-ci`
- Run evals with OpenAI compatible API (e.g., local LLMs): `LLM_PROVIDER=openaicompatible OPENAI_COMPATIBLE_API_URL=http://localhost:8080/v1 OPENAI_COMPATIBLE_MODEL=llama-3 make evals-ci`
- Run streaming benchmarks: `go test -bench=. -benchmem ./llm/... ./streaming/...`
- Run telemetry tests: `go test -v ./telemetry/...`
- Validate e2e CI shard coverage: `cd e2e && node scripts/ci-test-groups.mjs validate`
- List files assigned to a specific e2e CI shard/group: `cd e2e && node scripts/ci-test-groups.mjs list <group-name>`

## OpenTelemetry / Tracing

The plugin uses OpenTelemetry for distributed tracing. Key architecture points:

- **Telemetry package** (`telemetry/`): Owns OTel initialization, attribute constants, and helpers. Use `telemetry.Tracer()` to get a tracer and `telemetry.SpanFromContext(ctx)` to get the current span.
- **context.Context threading**: All functions in the request pipeline accept `ctx context.Context` as the first parameter. Always propagate ctx from entry points (HTTP handlers, plugin hooks) through to LLM calls and external services.
- **Span instrumentation**: Spans are created in `bifrost/` (LLM calls), `llm/tools.go` (tool resolution), `conversations/tool_handling.go` (tool call handling), `mcp/` (MCP tool calls), `search/` (semantic search), `websearch/` (Brave/Google), and `streaming/` (post streaming). The `otelgin` middleware auto-creates HTTP spans.
- **Adding new spans**: Use `ctx, span := telemetry.Tracer().Start(ctx, "span name", trace.WithAttributes(...))` and `defer span.End()`. Record errors with `span.RecordError(err)` and `span.SetStatus(codes.Error, msg)`. Use attribute keys from `telemetry/attributes.go`.
- **Config**: `TelemetryOutput` (string: `off` / `logs` / `otlp`) and `OpenTelemetryEndpoint` (string, e.g. `localhost:4317`) in plugin settings. `logs` mode pipes finished spans through `pluginapi.LogService` via `telemetry.NewLogSpanProcessor` for admins without an OTLP collector. `otlp` mode requires `OpenTelemetryEndpoint`.
- **Local testing**: `docker compose -f dev/docker-compose.otel.yml up -d` starts Grafana Tempo (OTLP on `localhost:4317`) and Grafana at `http://localhost:3001` (anonymous Admin, Tempo datasource preprovisioned). Open Explore → Tempo to view traces.
- **Context aliasing**: In files where a `context *llm.Context` parameter shadows the `context` package, use `stdcontext` as the import alias for `"context"`.

## Code Style Guidelines
- Go: Follow Go standard formatting conventions according to goimports
- TypeScript/React: Use 4-space indentation, PascalCase for components, strict typing, always use styled-components, never use style properties
Expand Down
3 changes: 3 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"time"

"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"

"github.com/mattermost/mattermost-plugin-agents/bifrost"
"github.com/mattermost/mattermost-plugin-agents/bots"
"github.com/mattermost/mattermost-plugin-agents/config"
Expand Down Expand Up @@ -216,6 +218,7 @@ func (a *API) SetConversationService(svc *conversation.Service) {
// ServeHTTP handles HTTP requests to the plugin
func (a *API) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
router := gin.Default()
router.Use(otelgin.Middleware("mattermost-ai-agents"))
router.Use(a.ginlogger)
router.Use(a.metricsMiddleware)

Expand Down
13 changes: 6 additions & 7 deletions api/api_channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,19 @@
package api

import (
stdcontext "context"
"encoding/json"
"errors"
"fmt"
"net/http"

"errors"

"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render"
"github.com/mattermost/mattermost-plugin-agents/bots"
"github.com/mattermost/mattermost-plugin-agents/channels"
"github.com/mattermost/mattermost-plugin-agents/llm"
"github.com/mattermost/mattermost-plugin-agents/prompts"
"github.com/mattermost/mattermost-plugin-agents/streaming"
"github.com/mattermost/mattermost-plugin-agents/telemetry"
"github.com/mattermost/mattermost/server/public/model"
)

Expand Down Expand Up @@ -151,7 +150,7 @@ func (a *API) handleChannelAnalysis(c *gin.Context) {
"Prompt": data.Prompt,
}

result, err := analyzer.AnalyzeChannel(llmContext, channel.Id, userID, bot.GetMMBot().UserId, analysisData)
result, err := analyzer.AnalyzeChannel(c.Request.Context(), llmContext, channel.Id, userID, bot.GetMMBot().UserId, analysisData)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to analyze channel: %w", err))
return
Expand All @@ -160,7 +159,7 @@ func (a *API) handleChannelAnalysis(c *gin.Context) {
// Create analysis post with conversation ID for streaming turn persistence
analysisPost := a.makeAnalysisPost(user.Locale, "", data.AnalysisType, result.ConversationID)

if err := a.streamingService.StreamToNewDM(stdcontext.Background(), bot.GetMMBot().UserId, result.Stream, user.Id, analysisPost, ""); err != nil {
if err := a.streamingService.StreamToNewDM(telemetry.DetachContext(c.Request.Context()), bot.GetMMBot().UserId, result.Stream, user.Id, analysisPost, ""); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
Expand Down Expand Up @@ -249,7 +248,7 @@ func (a *API) handleInterval(c *gin.Context) {

// Call channels interval processing with conversation entity
result, err := channels.New(bot.LLM(), a.prompts, a.mmClient, a.dbClient, a.convService).Interval(
context, channel.Id, userID, bot.GetMMBot().UserId, data.StartTime, data.EndTime, promptPreset,
c.Request.Context(), context, channel.Id, userID, bot.GetMMBot().UserId, data.StartTime, data.EndTime, promptPreset,
)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
Expand All @@ -262,7 +261,7 @@ func (a *API) handleInterval(c *gin.Context) {
post.AddProp(streaming.ConversationIDProp, result.ConversationID)

// Stream result to new DM
if err := a.streamingService.StreamToNewDM(stdcontext.Background(), bot.GetMMBot().UserId, result.Stream, user.Id, post, ""); err != nil {
if err := a.streamingService.StreamToNewDM(telemetry.DetachContext(c.Request.Context()), bot.GetMMBot().UserId, result.Stream, user.Id, post, ""); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
Expand Down
11 changes: 5 additions & 6 deletions api/api_llm_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ func (a *API) prepareAgentBridgeCompletion(
return nil, llm.CompletionRequest{}, nil, nil, nil, http.StatusBadRequest, errors.New("no eligible tools available for this agent")
}

scopedTools := llm.NewToolStore(nil, false)
scopedTools := llm.NewToolStore()
for _, name := range allowedToolNames {
tool := llmRequest.Context.Tools.GetTool(name)
if tool == nil {
Expand Down Expand Up @@ -502,12 +502,12 @@ func (a *API) streamLLMResponse(c *gin.Context, bot *bots.Bot, llmRequest llm.Co
var err error
if shouldExecute != nil {
var runResult *toolrunner.ToolRunResult
runResult, err = toolrunner.New(bot.LLM()).Run(llmRequest, shouldExecute, nil, opts...)
runResult, err = toolrunner.New(bot.LLM()).Run(c.Request.Context(), llmRequest, shouldExecute, nil, opts...)
if runResult != nil {
streamResult = runResult.Stream
}
} else {
streamResult, err = bot.LLM().ChatCompletion(llmRequest, opts...)
streamResult, err = bot.LLM().ChatCompletion(c.Request.Context(), llmRequest, opts...)
}
if err != nil {
// If streaming hasn't started, we can still send a JSON error
Expand Down Expand Up @@ -542,14 +542,13 @@ func (a *API) streamLLMResponse(c *gin.Context, bot *bots.Bot, llmRequest llm.Co
}
}

// handleNonStreamingLLMResponse handles non-streaming LLM responses.
// When shouldExecute is non-nil, the call is routed through a toolrunner so
// allowlisted tool calls are auto-executed; the runner's text stream is
// drained into a single concatenated string before responding, mirroring
// what ChatCompletionNoStream would have produced.
func (a *API) handleNonStreamingLLMResponse(c *gin.Context, bot *bots.Bot, llmRequest llm.CompletionRequest, shouldExecute func(llm.ToolCall) bool, opts ...llm.LanguageModelOption) {
if shouldExecute == nil {
response, err := bot.LLM().ChatCompletionNoStream(llmRequest, opts...)
response, err := bot.LLM().ChatCompletionNoStream(c.Request.Context(), llmRequest, opts...)
if err != nil {
c.JSON(http.StatusInternalServerError, bridgeclient.ErrorResponse{
Error: fmt.Sprintf("failed to complete LLM request: %v", err),
Expand All @@ -562,7 +561,7 @@ func (a *API) handleNonStreamingLLMResponse(c *gin.Context, bot *bots.Bot, llmRe
return
}

runResult, err := toolrunner.New(bot.LLM()).Run(llmRequest, shouldExecute, nil, opts...)
runResult, err := toolrunner.New(bot.LLM()).Run(c.Request.Context(), llmRequest, shouldExecute, nil, opts...)
if err != nil {
c.JSON(http.StatusInternalServerError, bridgeclient.ErrorResponse{
Error: fmt.Sprintf("failed to complete LLM request: %v", err),
Expand Down
4 changes: 0 additions & 4 deletions api/api_no_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,6 @@ func (p *noToolsTestMCPProvider) GetToolsForUser(string) ([]llm.Tool, *mcp.Error

type noToolsTestContextConfigProvider struct{}

func (p *noToolsTestContextConfigProvider) GetEnableLLMTrace() bool {
return false
}

func (p *noToolsTestContextConfigProvider) GetServiceByID(string) (llm.ServiceConfig, bool) {
return llm.ServiceConfig{}, false
}
Expand Down
18 changes: 9 additions & 9 deletions api/api_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package api

import (
stdcontext "context"
"errors"
"fmt"
"net/http"
Expand All @@ -16,6 +15,7 @@ import (
"github.com/mattermost/mattermost-plugin-agents/mmapi"
"github.com/mattermost/mattermost-plugin-agents/react"
"github.com/mattermost/mattermost-plugin-agents/streaming"
"github.com/mattermost/mattermost-plugin-agents/telemetry"
"github.com/mattermost/mattermost-plugin-agents/threads"
"github.com/mattermost/mattermost/server/public/model"
)
Expand Down Expand Up @@ -82,7 +82,7 @@ func (a *API) handleReact(c *gin.Context) {
emojiName, err := react.New(
bot.LLM(),
a.prompts,
).Resolve(post.Message, context)
).Resolve(c.Request.Context(), post.Message, context)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
Expand Down Expand Up @@ -154,13 +154,13 @@ func (a *API) handleThreadAnalysis(c *gin.Context) {
switch data.AnalysisType {
case "summarize_thread":
title = TitleThreadSummary
analyzeResult, err = analyzer.Summarize(post.Id, llmContext, botUserID, userID)
analyzeResult, err = analyzer.Summarize(c.Request.Context(), post.Id, llmContext, botUserID, userID)
case "action_items":
title = TitleFindActionItems
analyzeResult, err = analyzer.FindActionItems(post.Id, llmContext, botUserID, userID)
analyzeResult, err = analyzer.FindActionItems(c.Request.Context(), post.Id, llmContext, botUserID, userID)
case "open_questions":
title = TitleFindOpenQuestions
analyzeResult, err = analyzer.FindOpenQuestions(post.Id, llmContext, botUserID, userID)
analyzeResult, err = analyzer.FindOpenQuestions(c.Request.Context(), post.Id, llmContext, botUserID, userID)
}
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to analyze thread: %w", err))
Expand All @@ -169,7 +169,7 @@ func (a *API) handleThreadAnalysis(c *gin.Context) {

// Create analysis post with conversation ID
analysisPost := a.makeAnalysisPost(user.Locale, post.Id, data.AnalysisType, analyzeResult.ConversationID)
if err := a.streamingService.StreamToNewDM(stdcontext.Background(), botUserID, analyzeResult.Stream, user.Id, analysisPost, post.Id); err != nil {
if err := a.streamingService.StreamToNewDM(telemetry.DetachContext(c.Request.Context()), botUserID, analyzeResult.Stream, user.Id, analysisPost, post.Id); err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
Expand Down Expand Up @@ -277,7 +277,7 @@ func (a *API) handleRegenerate(c *gin.Context) {
return
}

err := a.conversationsService.HandleRegenerate(userID, post, channel)
err := a.conversationsService.HandleRegenerate(c.Request.Context(), userID, post, channel)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("unable to regenerate post: %w", err))
return
Expand Down Expand Up @@ -316,7 +316,7 @@ func (a *API) handleToolCall(c *gin.Context) {
return
}

if err := a.conversationsService.HandleToolCall(userID, post, channel, data.AcceptedToolIDs); err != nil {
if err := a.conversationsService.HandleToolCall(c.Request.Context(), userID, post, channel, data.AcceptedToolIDs); err != nil {
c.AbortWithError(toolApprovalHTTPStatus(err), err)
return
}
Expand Down Expand Up @@ -370,7 +370,7 @@ func (a *API) handleToolResult(c *gin.Context) {
return
}

if err := a.conversationsService.HandleToolResult(userID, post, channel, data.AcceptedToolIDs); err != nil {
if err := a.conversationsService.HandleToolResult(c.Request.Context(), userID, post, channel, data.AcceptedToolIDs); err != nil {
c.AbortWithError(toolApprovalHTTPStatus(err), err)
return
}
Expand Down
5 changes: 3 additions & 2 deletions api/fake_llm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package api

import (
"context"
"fmt"
"sync"

Expand Down Expand Up @@ -48,7 +49,7 @@ type FakeLLM struct {
}

// ChatCompletion implements streaming completion
func (f *FakeLLM) ChatCompletion(conversation llm.CompletionRequest, opts ...llm.LanguageModelOption) (*llm.TextStreamResult, error) {
func (f *FakeLLM) ChatCompletion(_ context.Context, conversation llm.CompletionRequest, opts ...llm.LanguageModelOption) (*llm.TextStreamResult, error) {
var cfg llm.LanguageModelConfig
for _, opt := range opts {
opt(&cfg)
Expand Down Expand Up @@ -109,7 +110,7 @@ func (f *FakeLLM) ChatCompletion(conversation llm.CompletionRequest, opts ...llm
}

// ChatCompletionNoStream implements non-streaming completion
func (f *FakeLLM) ChatCompletionNoStream(conversation llm.CompletionRequest, opts ...llm.LanguageModelOption) (string, error) {
func (f *FakeLLM) ChatCompletionNoStream(_ context.Context, conversation llm.CompletionRequest, opts ...llm.LanguageModelOption) (string, error) {
var cfg llm.LanguageModelConfig
for _, opt := range opts {
opt(&cfg)
Expand Down
Loading
Loading