Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ jobs:
- name: Run tests
run: task test
env:
TELEMETRY_ENABLED: true
TELEMETRY_API_KEY: ${{ secrets.TELEMETRY_API_KEY }}
TELEMETRY_ENDPOINT: ${{ secrets.TELEMETRY_ENDPOINT }}
TELEMETRY_HEADER: ${{ secrets.TELEMETRY_HEADER }}
Expand Down Expand Up @@ -93,7 +92,6 @@ jobs:
- name: Build
run: task build
env:
TELEMETRY_ENABLED: true
TELEMETRY_API_KEY: ${{ secrets.TELEMETRY_API_KEY }}
TELEMETRY_ENDPOINT: ${{ secrets.TELEMETRY_ENDPOINT }}
TELEMETRY_HEADER: ${{ secrets.TELEMETRY_HEADER }}
10 changes: 4 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ COPY . ./
ARG GIT_TAG GIT_COMMIT BUILD_DATE
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=1 go build -trimpath -ldflags "-s -w -X 'github.com/docker/cagent/cmd/root.Version=$GIT_TAG' -X 'github.com/docker/cagent/cmd/root.Commit=$GIT_COMMIT' -X 'github.com/docker/cagent/cmd/root.BuildTime=$BUILD_DATE' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEnabled=$TELEMETRY_ENABLED' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEndpoint=$TELEMETRY_ENDPOINT' -X 'github.com/docker/cagent/internal/telemetry.TelemetryAPIKey=$TELEMETRY_API_KEY' -X 'github.com/docker/cagent/internal/telemetry.TelemetryHeader=$TELEMETRY_HEADER'" -o /agent .
CGO_ENABLED=1 go build -trimpath -ldflags "-s -w -X 'github.com/docker/cagent/cmd/root.Version=$GIT_TAG' -X 'github.com/docker/cagent/cmd/root.Commit=$GIT_COMMIT' -X 'github.com/docker/cagent/cmd/root.BuildTime=$BUILD_DATE' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEndpoint=$TELEMETRY_ENDPOINT' -X 'github.com/docker/cagent/internal/telemetry.TelemetryAPIKey=$TELEMETRY_API_KEY' -X 'github.com/docker/cagent/internal/telemetry.TelemetryHeader=$TELEMETRY_HEADER'" -o /agent .

FROM --platform=$BUILDPLATFORM golang:1.25.0-alpine3.22 AS builder-base
WORKDIR /src
Expand All @@ -23,12 +23,10 @@ ARG GIT_TAG GIT_COMMIT BUILD_DATE
ARG TELEMETRY_API_KEY
ARG TELEMETRY_ENDPOINT
ARG TELEMETRY_HEADER
ARG TELEMETRY_ENABLED

ENV TELEMETRY_API_KEY=${TELEMETRY_API_KEY}
ENV TELEMETRY_ENDPOINT=${TELEMETRY_ENDPOINT}
ENV TELEMETRY_HEADER=${TELEMETRY_HEADER}
ENV TELEMETRY_ENABLED=${TELEMETRY_ENABLED}

FROM builder-base AS builder-darwin
RUN apk add clang
Expand All @@ -37,7 +35,7 @@ RUN --mount=type=bind,from=osxcross,src=/osxsdk,target=/xx-sdk \
--mount=type=cache,target=/root/.cache,id=docker-ai-$TARGETPLATFORM \
--mount=type=cache,target=/go/pkg/mod <<EOT
set -x
CGO_ENABLED=1 xx-go build -trimpath -ldflags "-s -w -X 'github.com/docker/cagent/cmd/root.Version=$GIT_TAG' -X 'github.com/docker/cagent/cmd/root.Commit=$GIT_COMMIT' -X 'github.com/docker/cagent/cmd/root.BuildTime=$BUILD_DATE' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEnabled=$TELEMETRY_ENABLED' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEndpoint=$TELEMETRY_ENDPOINT' -X 'github.com/docker/cagent/internal/telemetry.TelemetryAPIKey=$TELEMETRY_API_KEY' -X 'github.com/docker/cagent/internal/telemetry.TelemetryHeader=$TELEMETRY_HEADER'" -o /binaries/cagent-$TARGETOS-$TARGETARCH .
CGO_ENABLED=1 xx-go build -trimpath -ldflags "-s -w -X 'github.com/docker/cagent/cmd/root.Version=$GIT_TAG' -X 'github.com/docker/cagent/cmd/root.Commit=$GIT_COMMIT' -X 'github.com/docker/cagent/cmd/root.BuildTime=$BUILD_DATE' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEndpoint=$TELEMETRY_ENDPOINT' -X 'github.com/docker/cagent/internal/telemetry.TelemetryAPIKey=$TELEMETRY_API_KEY' -X 'github.com/docker/cagent/internal/telemetry.TelemetryHeader=$TELEMETRY_HEADER'" -o /binaries/cagent-$TARGETOS-$TARGETARCH .
xx-verify --static /binaries/cagent-darwin-$TARGETARCH
EOT

Expand All @@ -48,7 +46,7 @@ COPY . ./
RUN --mount=type=cache,target=/root/.cache,id=docker-ai-$TARGETPLATFORM \
--mount=type=cache,target=/go/pkg/mod <<EOT
set -x
CGO_ENABLED=1 xx-go build -trimpath -ldflags "-s -w -linkmode=external -extldflags '-static' -X 'github.com/docker/cagent/cmd/root.Version=$GIT_TAG' -X 'github.com/docker/cagent/cmd/root.Commit=$GIT_COMMIT' -X 'github.com/docker/cagent/cmd/root.BuildTime=$BUILD_DATE' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEnabled=$TELEMETRY_ENABLED' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEndpoint=$TELEMETRY_ENDPOINT' -X 'github.com/docker/cagent/internal/telemetry.TelemetryAPIKey=$TELEMETRY_API_KEY' -X 'github.com/docker/cagent/internal/telemetry.TelemetryHeader=$TELEMETRY_HEADER'" -o /binaries/cagent-$TARGETOS-$TARGETARCH .
CGO_ENABLED=1 xx-go build -trimpath -ldflags "-s -w -linkmode=external -extldflags '-static' -X 'github.com/docker/cagent/cmd/root.Version=$GIT_TAG' -X 'github.com/docker/cagent/cmd/root.Commit=$GIT_COMMIT' -X 'github.com/docker/cagent/cmd/root.BuildTime=$BUILD_DATE' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEndpoint=$TELEMETRY_ENDPOINT' -X 'github.com/docker/cagent/internal/telemetry.TelemetryAPIKey=$TELEMETRY_API_KEY' -X 'github.com/docker/cagent/internal/telemetry.TelemetryHeader=$TELEMETRY_HEADER'" -o /binaries/cagent-$TARGETOS-$TARGETARCH .
xx-verify --static /binaries/cagent-linux-$TARGETARCH
EOT

Expand All @@ -58,7 +56,7 @@ COPY . ./
RUN --mount=type=cache,target=/root/.cache,id=docker-ai-$TARGETPLATFORM \
--mount=type=cache,target=/go/pkg/mod <<EOT
set -x
CGO_ENABLED=1 CC="zig cc -target x86_64-windows-gnu" CXX="zig c++ -target x86_64-windows-gnu" xx-go build -trimpath -ldflags "-s -w -X 'github.com/docker/cagent/cmd/root.Version=$GIT_TAG' -X 'github.com/docker/cagent/cmd/root.Commit=$GIT_COMMIT' -X 'github.com/docker/cagent/cmd/root.BuildTime=$BUILD_DATE' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEnabled=$TELEMETRY_ENABLED' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEndpoint=$TELEMETRY_ENDPOINT' -X 'github.com/docker/cagent/internal/telemetry.TelemetryAPIKey=$TELEMETRY_API_KEY' -X 'github.com/docker/cagent/internal/telemetry.TelemetryHeader=$TELEMETRY_HEADER'" -o /binaries/cagent-$TARGETOS-$TARGETARCH .
CGO_ENABLED=1 CC="zig cc -target x86_64-windows-gnu" CXX="zig c++ -target x86_64-windows-gnu" xx-go build -trimpath -ldflags "-s -w -X 'github.com/docker/cagent/cmd/root.Version=$GIT_TAG' -X 'github.com/docker/cagent/cmd/root.Commit=$GIT_COMMIT' -X 'github.com/docker/cagent/cmd/root.BuildTime=$BUILD_DATE' -X 'github.com/docker/cagent/internal/telemetry.TelemetryEndpoint=$TELEMETRY_ENDPOINT' -X 'github.com/docker/cagent/internal/telemetry.TelemetryAPIKey=$TELEMETRY_API_KEY' -X 'github.com/docker/cagent/internal/telemetry.TelemetryHeader=$TELEMETRY_HEADER'" -o /binaries/cagent-$TARGETOS-$TARGETARCH .
ls -la /binaries
mv /binaries/cagent-$TARGETOS-$TARGETARCH /binaries/cagent-$TARGETOS-$TARGETARCH.exe
xx-verify --static /binaries/cagent-windows-$TARGETARCH.exe
Expand Down
6 changes: 3 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ vars:
BUILD_DATE:
sh: date -u +"%Y-%m-%dT%H:%M:%SZ"
GO_SOURCES: "**/*.go"
BUILD_ARGS: '--build-arg GIT_TAG="{{.GIT_TAG}}" --build-arg GIT_COMMIT="{{.GIT_COMMIT}}" --build-arg BUILD_DATE="{{.BUILD_DATE}}" --build-arg TELEMETRY_ENABLED="{{.TELEMETRY_ENABLED | default "true"}}" --build-arg TELEMETRY_ENDPOINT="{{.TELEMETRY_ENDPOINT}}" --build-arg TELEMETRY_API_KEY="{{.TELEMETRY_API_KEY}}" --build-arg TELEMETRY_HEADER="{{.TELEMETRY_HEADER}}"'
LDFLAGS: '-X "github.com/docker/cagent/cmd/root.Version={{.GIT_TAG}}" -X "github.com/docker/cagent/cmd/root.Commit={{.GIT_COMMIT}}" -X "github.com/docker/cagent/cmd/root.BuildTime={{.BUILD_DATE}}" -X "github.com/docker/cagent/internal/telemetry.TelemetryEnabled={{.TELEMETRY_ENABLED | default "true"}}" -X "github.com/docker/cagent/internal/telemetry.TelemetryEndpoint={{.TELEMETRY_ENDPOINT}}" -X "github.com/docker/cagent/internal/telemetry.TelemetryAPIKey={{.TELEMETRY_API_KEY}}" -X "github.com/docker/cagent/internal/telemetry.TelemetryHeader={{.TELEMETRY_HEADER}}"'
BUILD_ARGS: '--build-arg GIT_TAG="{{.GIT_TAG}}" --build-arg GIT_COMMIT="{{.GIT_COMMIT}}" --build-arg BUILD_DATE="{{.BUILD_DATE}}" --build-arg TELEMETRY_ENDPOINT="{{.TELEMETRY_ENDPOINT}}" --build-arg TELEMETRY_API_KEY="{{.TELEMETRY_API_KEY}}" --build-arg TELEMETRY_HEADER="{{.TELEMETRY_HEADER}}"'
LDFLAGS: '-X "github.com/docker/cagent/cmd/root.Version={{.GIT_TAG}}" -X "github.com/docker/cagent/cmd/root.Commit={{.GIT_COMMIT}}" -X "github.com/docker/cagent/cmd/root.BuildTime={{.BUILD_DATE}}" -X "github.com/docker/cagent/internal/telemetry.TelemetryEndpoint={{.TELEMETRY_ENDPOINT}}" -X "github.com/docker/cagent/internal/telemetry.TelemetryAPIKey={{.TELEMETRY_API_KEY}}" -X "github.com/docker/cagent/internal/telemetry.TelemetryHeader={{.TELEMETRY_HEADER}}"'

tasks:
default:
Expand Down Expand Up @@ -40,7 +40,7 @@ tasks:
sources:
- "{{.GO_SOURCES}}"
- ".golangci.yml"

format:
desc: Format code
cmds:
Expand Down
4 changes: 2 additions & 2 deletions docs/TELEMETRY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Telemetry for cagent

The telemetry system in `cagent` collects **anonymous usage data** to help improve the tool. All events are processed **asynchronously** in the background and never block command execution. Telemetry can be disabled at any time.
The telemetry system in `cagent` collects **anonymous usage data** to help improve the tool. All events are processed **synchronously** when recorded. Telemetry can be disabled at any time.

On first startup, `cagent` displays a notice about telemetry collection and how to disable it, so users are always informed.

Expand Down Expand Up @@ -68,7 +68,7 @@ The system uses structured, type-safe events:
- **Token events**: Track LLM token usage by model, session, and cost
- **Session events**: Track agent session lifecycle with separate start/end events and aggregate metrics

Events are queued in a buffered channel (1000 events) and processed asynchronously by a background goroutine. Use `client.Shutdown(ctx)` at program exit to flush remaining events.
Events are processed synchronously when `Track()` is called, sending HTTP requests immediately.

### Configuration

Expand Down
146 changes: 45 additions & 101 deletions internal/telemetry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,52 @@ package telemetry

import (
"context"
"fmt"
"log/slog"
"net/http"
"sync/atomic"
"time"
)

// NewClient creates a new simplified telemetry client with explicit debug control
func NewClient(logger *slog.Logger, enabled, debugMode bool, version string) (*Client, error) {
return NewClientWithHTTPClient(logger, enabled, debugMode, version, &http.Client{Timeout: 30 * time.Second})
// telemetryLogger wraps slog.Logger to automatically prepend "[Telemetry]" to all messages
type telemetryLogger struct {
logger *slog.Logger
}

// NewClientWithHTTPClient creates a new telemetry client with a custom HTTP client (useful for testing)
func NewClientWithHTTPClient(logger *slog.Logger, enabled, debugMode bool, version string, httpClient HTTPClient) (*Client, error) {
// Debug mode only affects output destination, not whether telemetry is enabled
// Respect the user's enabled setting
// NewTelemetryLogger creates a new telemetry logger that automatically prepends "[Telemetry]" to all messages
func NewTelemetryLogger(logger *slog.Logger) *telemetryLogger {
return &telemetryLogger{logger: logger}
}

// Debug logs a debug message with "[Telemetry]" prefix
func (tl *telemetryLogger) Debug(msg string, args ...any) {
tl.logger.Debug("🔎 [Telemetry] "+msg, args...)
}

// Info logs an info message with "[Telemetry]" prefix
func (tl *telemetryLogger) Info(msg string, args ...any) {
tl.logger.Info("💬 [Telemetry] "+msg, args...)
}

// Warn logs a warning message with "[Telemetry]" prefix
func (tl *telemetryLogger) Warn(msg string, args ...any) {
tl.logger.Warn("⚠️ [Telemetry] "+msg, args...)
}

// Error logs an error message with "[Telemetry]" prefix
func (tl *telemetryLogger) Error(msg string, args ...any) {
tl.logger.Error("❌ [Telemetry] "+msg, args...)
}

// Enabled returns whether the logger is enabled for the given level
func (tl *telemetryLogger) Enabled(ctx context.Context, level slog.Level) bool {
return tl.logger.Enabled(ctx, level)
}

func NewClient(logger *slog.Logger, enabled, debugMode bool, version string, customHttpClient ...*http.Client) (*Client, error) {
Comment thread
derekmisler marked this conversation as resolved.
telemetryLogger := NewTelemetryLogger(logger)

if !enabled {
return &Client{
logger: logger,
logger: telemetryLogger,
enabled: false,
version: version,
}, nil
Expand All @@ -31,25 +57,29 @@ func NewClientWithHTTPClient(logger *slog.Logger, enabled, debugMode bool, versi
apiKey := getTelemetryAPIKey()
header := getTelemetryHeader()

var httpClient *http.Client
if len(customHttpClient) > 0 && customHttpClient[0] != nil {
httpClient = customHttpClient[0]
} else {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
Comment thread
derekmisler marked this conversation as resolved.

client := &Client{
logger: logger,
logger: telemetryLogger,
enabled: enabled,
debugMode: debugMode,
httpClient: httpClient,
endpoint: endpoint,
apiKey: apiKey,
header: header,
version: version,
eventChan: make(chan EventWithContext, 1000), // Buffer for 1000 events
stopChan: make(chan struct{}),
done: make(chan struct{}),
}

if debugMode {
hasEndpoint := endpoint != ""
hasAPIKey := apiKey != ""
hasHeader := header != ""
logger.Debug("Telemetry configuration",
telemetryLogger.Debug("Telemetry configuration",
"enabled", enabled,
"has_endpoint", hasEndpoint,
"has_api_key", hasAPIKey,
Expand All @@ -58,91 +88,5 @@ func NewClientWithHTTPClient(logger *slog.Logger, enabled, debugMode bool, versi
)
}

// Start background event processor
go client.processEvents()

return client, nil
}

// IsEnabled returns whether telemetry is enabled
func (tc *Client) IsEnabled() bool {
tc.mu.RLock()
defer tc.mu.RUnlock()
return tc.enabled
}

// Shutdown gracefully shuts down the telemetry client
func (tc *Client) Shutdown(ctx context.Context) error {
tc.RecordSessionEnd(ctx)

if !tc.enabled {
return nil
}

// Signal shutdown to background goroutine
close(tc.stopChan)

// Wait for background processing to complete with timeout
select {
case <-tc.done:
return nil
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
return fmt.Errorf("timeout waiting for telemetry shutdown")
}
}

// processEvents runs in a background goroutine to process telemetry events
func (tc *Client) processEvents() {
defer close(tc.done)

if tc.debugMode {
tc.logger.Debug("🔄 Background event processor started")
}

for {
select {
case event := <-tc.eventChan:
if tc.debugMode {
tc.logger.Debug("🔄 Processing event from channel", "event_type", event.eventName)
}
tc.processEvent(event)
case <-tc.stopChan:
if tc.debugMode {
tc.logger.Debug("🛑 Background processor received stop signal")
}
// Drain remaining events before shutting down
for {
select {
case event := <-tc.eventChan:
if tc.debugMode {
tc.logger.Debug("🔄 Draining event during shutdown", "event_type", event.eventName)
}
tc.processEvent(event)
default:
if tc.debugMode {
tc.logger.Debug("🛑 Background processor shutting down")
}
return
}
}
}
}
}

// processEvent handles individual events in the background goroutine
func (tc *Client) processEvent(eventCtx EventWithContext) {
// Track that we're processing this event
atomic.AddInt64(&tc.requestCount, 1)
defer atomic.AddInt64(&tc.requestCount, -1)

event := tc.createEvent(eventCtx.eventName, eventCtx.properties)

if tc.debugMode {
tc.printEvent(&event)
}

// Always send the event (regardless of debug mode)
tc.sendEvent(&event)
}
29 changes: 9 additions & 20 deletions internal/telemetry/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ package telemetry

import (
"context"
"fmt"
"time"
)

// Track records a structured telemetry event with type-safe properties (non-blocking)
// Track records a structured telemetry event with type-safe properties (synchronous)
// This is the only method for telemetry tracking, all event-specific methods are wrappers around this one
func (tc *Client) Track(ctx context.Context, structuredEvent StructuredEvent) {
eventType := structuredEvent.GetEventType()
Expand All @@ -20,33 +19,23 @@ func (tc *Client) Track(ctx context.Context, structuredEvent StructuredEvent) {
return
}

// Send event to background processor (non-blocking)
if !tc.enabled {
return
}

// Debug logging to track event flow
if tc.debugMode {
tc.logger.Debug("📤 Queuing telemetry event", "event_type", eventType, "channel_length", len(tc.eventChan))
tc.logger.Debug("Processing telemetry event synchronously", "event_type", eventType)
}

select {
case tc.eventChan <- EventWithContext{
eventName: string(eventType),
properties: properties,
}:
// Event queued successfully
if tc.debugMode {
tc.logger.Debug("✅ Event queued successfully", "event_type", eventType, "channel_length", len(tc.eventChan))
}
default:
// Channel full - drop event to avoid blocking
if tc.debugMode {
fmt.Printf("⚠️ Telemetry event dropped (buffer full): %s\n", eventType)
}
// Log dropped event for visibility
tc.logger.Warn("Telemetry event dropped", "reason", "buffer_full", "event_name", eventType)
event := tc.createEvent(string(eventType), properties)

if tc.debugMode {
tc.printEvent(&event)
}

// Always send the event synchronously
tc.sendEvent(&event)
}

// RecordSessionStart initializes session tracking
Expand Down
12 changes: 3 additions & 9 deletions internal/telemetry/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,6 @@ var (
globalTelemetryDebugMode = false
)

// SetGlobalToolTelemetryClient sets the global client for tool telemetry
// This allows other packages to record tool events without context passing
// This is now optional - if not called, automatic initialization will happen
func SetGlobalToolTelemetryClient(client *Client, logger *slog.Logger) {
globalToolTelemetryClient = client
// Logger is now handled internally by automatic initialization
}

// GetGlobalTelemetryClient returns the global telemetry client for adding to context
func GetGlobalTelemetryClient() *Client {
ensureGlobalTelemetryInitialized()
Expand Down Expand Up @@ -87,7 +79,9 @@ func ensureGlobalTelemetryInitialized() {
globalToolTelemetryClient = client

if debugMode {
logger.Info("Auto-initialized telemetry", "enabled", enabled, "debug", debugMode)
// Use the telemetry logger wrapper for consistency
telemetryLogger := NewTelemetryLogger(logger)
telemetryLogger.Info("Auto-initialized telemetry", "enabled", enabled, "debug", debugMode)
}
})
}
Expand Down
Loading