Skip to content
Draft
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
68 changes: 57 additions & 11 deletions server/cmd/api/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"sync/atomic"
"time"
"unicode/utf8"

chiMiddleware "github.com/go-chi/chi/v5/middleware"

Expand All @@ -18,7 +19,33 @@ import (
type telemetryCtxKey struct{}

type telemetryRequestCtx struct {
operationID string
operationID string
code string
codeTruncated bool
}

// maxAPICallCodeBytes caps the source recorded on an api_call event. Playwright
// snippets are small in practice; the cap keeps a pathological one from eating
// the envelope size limit and nulling the whole payload.
const maxAPICallCodeBytes = 8192

// RecordTelemetryCode attaches code submitted with the request to its api_call
// event, clipped to maxAPICallCodeBytes. It is a no-op when telemetry is off or
// the request is not one the middleware tracks.
func RecordTelemetryCode(ctx context.Context, code string) {
tc, ok := ctx.Value(telemetryCtxKey{}).(*telemetryRequestCtx)
if !ok {
return
}
if len(code) > maxAPICallCodeBytes {
code, tc.codeTruncated = code[:maxAPICallCodeBytes], true
// Drop a rune the cut landed inside, so the recorded code stays valid
// UTF-8 instead of ending in a replacement character.
for len(code) > 0 && !utf8.ValidString(code) {
code = code[:len(code)-1]
}
}
tc.code = code
}

// Process-wide toggle for the api_call middleware. Flipped by
Expand All @@ -35,9 +62,12 @@ func DisableTelemetryMiddleware() { telemetryMiddlewareEnabled.Store(false) }
// TelemetryMiddlewareEnabled reports the current state.
func TelemetryMiddlewareEnabled() bool { return telemetryMiddlewareEnabled.Load() }

// TelemetryHTTPMiddleware emits a BrowserApiCallEvent per documented operation,
// capturing the final status and wall-clock duration. publish is wired to
// TelemetrySession.Publish; the middleware ignores the returns.
// TelemetryHTTPMiddleware emits one event per documented operation, capturing
// the final status and wall-clock duration. Operations that drive the browser
// emit api_call under control; operations that manage the VM emit
// platform_api_call under platform, per the operation's x-telemetry-category in
// openapi.yaml. publish is wired to TelemetrySession.Publish; the middleware
// ignores the returns.
func TelemetryHTTPMiddleware(publish func(events.Event) (events.Envelope, bool)) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -55,23 +85,39 @@ func TelemetryHTTPMiddleware(publish func(events.Event) (events.Envelope, bool))
if tc.operationID == "" {
return
}
data, _ := json.Marshal(oapi.BrowserApiCallEventData{
eventData := oapi.BrowserApiCallEventData{
RequestId: chiMiddleware.GetReqID(ctx),
OperationId: tc.operationID,
Status: ww.Status(),
DurationMs: float32(time.Since(start).Microseconds()) / 1000.0,
})
}
if tc.code != "" {
eventData.Code = &tc.code
}
data, _ := json.Marshal(eventData)
eventType, category := apiCallEvent(tc.operationID)
publish(events.Event{
Ts: time.Now().UnixMicro(),
Type: "api_call",
Category: events.Control,
Source: oapi.BrowserEventSource{Kind: oapi.KernelApi},
Data: data,
Ts: time.Now().UnixMicro(),
Type: eventType,
Category: category,
Source: oapi.BrowserEventSource{Kind: oapi.KernelApi},
Data: data,
Truncated: tc.codeTruncated,
})
})
}
}

// apiCallEvent resolves the event type and category for an operation. An
// operation missing from the generated map falls back to platform so an
// unclassified route cannot dilute the control stream.
func apiCallEvent(operationID string) (string, oapi.TelemetryEventCategory) {
if cat, ok := events.CategoryForOperation(operationID); ok && cat == events.Control {
return "api_call", events.Control
}
return "platform_api_call", events.Platform
}

// TelemetryStrictMiddleware records the matched OpenAPI operationId onto the
// per-request scratch so TelemetryHTTPMiddleware can include it in the event.
func TelemetryStrictMiddleware() oapi.StrictMiddlewareFunc {
Expand Down
108 changes: 95 additions & 13 deletions server/cmd/api/api/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"

Expand Down Expand Up @@ -37,21 +38,23 @@ func (rp *recordingPublisher) snapshot() []events.Event {
return out
}

// Mirrors the oapi-codegen strict dispatcher: middleware chain -> inner
// handler -> response write.
func fakeStrictHandler(operationID string, status int, mws []oapi.StrictMiddlewareFunc) http.Handler {
// Mirrors the oapi-codegen strict dispatcher, running body inside the handler so
// a test can act on the request context the way a real handler does.
func fakeStrictHandlerFunc(operationID string, status int, body func(ctx context.Context)) http.Handler {
inner := oapi.StrictHandlerFunc(func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) {
body(ctx)
return nil, nil
})
for _, mw := range mws {
inner = mw(inner, operationID)
}
inner = TelemetryStrictMiddleware()(inner, operationID)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = inner(r.Context(), w, r, nil)
w.WriteHeader(status)
})
}

// noBody is the handler body for tests that only exercise the middleware.
func noBody(context.Context) {}

// Flips the package-level toggle on for the test, restoring prior state
// via t.Cleanup.
func withTelemetryMiddlewareEnabled(t *testing.T) {
Expand All @@ -70,9 +73,9 @@ func withTelemetryMiddlewareEnabled(t *testing.T) {
func TestTelemetryMiddleware_EmitsApiCallEventOnDocumentedRoute(t *testing.T) {
withTelemetryMiddlewareEnabled(t)
rp := &recordingPublisher{}
chain := chiHandler(t, rp.publish, "ProcessExec", http.StatusOK)
chain := chiHandler(t, rp.publish, "ClickMouse", http.StatusOK, noBody)

req := httptest.NewRequest(http.MethodPost, "/process/exec", nil)
req := httptest.NewRequest(http.MethodPost, "/computer/click_mouse", nil)
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)

Expand All @@ -88,18 +91,97 @@ func TestTelemetryMiddleware_EmitsApiCallEventOnDocumentedRoute(t *testing.T) {
OperationID string `json:"operation_id"`
Status int `json:"status"`
DurationMs float64 `json:"duration_ms"`
Code *string `json:"code"`
}
require.NoError(t, json.Unmarshal(ev.Data, &data))
assert.NotEmpty(t, data.RequestID, "request_id should be set by chi RequestID middleware")
assert.Equal(t, "ProcessExec", data.OperationID)
assert.Equal(t, "ClickMouse", data.OperationID)
assert.Equal(t, http.StatusOK, data.Status)
assert.GreaterOrEqual(t, data.DurationMs, 0.0)
assert.Nil(t, data.Code, "code is only recorded by handlers that submit code")
}

func TestTelemetryMiddleware_EmitsPlatformApiCallForVMOperations(t *testing.T) {
withTelemetryMiddlewareEnabled(t)
rp := &recordingPublisher{}
chain := chiHandler(t, rp.publish, "ProcessExec", http.StatusOK, noBody)

req := httptest.NewRequest(http.MethodPost, "/process/exec", nil)
chain.ServeHTTP(httptest.NewRecorder(), req)

captured := rp.snapshot()
require.Len(t, captured, 1)
assert.Equal(t, "platform_api_call", captured[0].Type)
assert.Equal(t, events.Platform, captured[0].Category)
}

// An operation the generated map does not know about must not land in control,
// which is the stream callers read to see what the agent did.
func TestTelemetryMiddleware_UnknownOperationIsPlatform(t *testing.T) {
withTelemetryMiddlewareEnabled(t)
rp := &recordingPublisher{}
chain := chiHandler(t, rp.publish, "SomeRouteAddedLater", http.StatusOK, noBody)

chain.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/whatever", nil))

captured := rp.snapshot()
require.Len(t, captured, 1)
assert.Equal(t, events.Platform, captured[0].Category)
}

func TestTelemetryMiddleware_RecordsSubmittedCode(t *testing.T) {
withTelemetryMiddlewareEnabled(t)
rp := &recordingPublisher{}
code := "await page.goto('https://example.com')"
chain := chiHandler(t, rp.publish, "ExecutePlaywrightCode", http.StatusOK, func(ctx context.Context) {
RecordTelemetryCode(ctx, code)
})

chain.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/playwright/execute", nil))

captured := rp.snapshot()
require.Len(t, captured, 1)
assert.Equal(t, events.Control, captured[0].Category)
assert.False(t, captured[0].Truncated)
var data struct {
Code *string `json:"code"`
}
require.NoError(t, json.Unmarshal(captured[0].Data, &data))
require.NotNil(t, data.Code)
assert.Equal(t, code, *data.Code)
}

func TestTelemetryMiddleware_ClipsOversizedCode(t *testing.T) {
withTelemetryMiddlewareEnabled(t)
rp := &recordingPublisher{}
// Ends on a multi-byte rune straddling the cap so the clip has to back off.
oversized := strings.Repeat("x", maxAPICallCodeBytes-1) + strings.Repeat("é", 10)
chain := chiHandler(t, rp.publish, "ExecutePlaywrightCode", http.StatusOK, func(ctx context.Context) {
RecordTelemetryCode(ctx, oversized)
})

chain.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/playwright/execute", nil))

captured := rp.snapshot()
require.Len(t, captured, 1)
assert.True(t, captured[0].Truncated)
var data struct {
Code string `json:"code"`
}
require.NoError(t, json.Unmarshal(captured[0].Data, &data))
assert.Equal(t, strings.Repeat("x", maxAPICallCodeBytes-1), data.Code)
}

// RecordTelemetryCode is called from handlers that also serve requests the
// middleware is not tracking, so it must tolerate a bare context.
func TestRecordTelemetryCode_NoopWithoutRequestScratch(t *testing.T) {
RecordTelemetryCode(context.Background(), "await page.goto('https://example.com')")
}

func TestTelemetryMiddleware_CapturesNonOKStatus(t *testing.T) {
withTelemetryMiddlewareEnabled(t)
rp := &recordingPublisher{}
chain := chiHandler(t, rp.publish, "ProcessExec", http.StatusInternalServerError)
chain := chiHandler(t, rp.publish, "ProcessExec", http.StatusInternalServerError, noBody)

req := httptest.NewRequest(http.MethodPost, "/process/exec", nil)
rec := httptest.NewRecorder()
Expand Down Expand Up @@ -131,7 +213,7 @@ func TestTelemetryMiddleware_SkipsUndocumentedRoutes(t *testing.T) {
func TestTelemetryMiddleware_ShortCircuitsWhenDisabled(t *testing.T) {
DisableTelemetryMiddleware()
rp := &recordingPublisher{}
chain := chiHandler(t, rp.publish, "ProcessExec", http.StatusOK)
chain := chiHandler(t, rp.publish, "ProcessExec", http.StatusOK, noBody)

req := httptest.NewRequest(http.MethodPost, "/process/exec", nil)
rec := httptest.NewRecorder()
Expand All @@ -142,9 +224,9 @@ func TestTelemetryMiddleware_ShortCircuitsWhenDisabled(t *testing.T) {

// Builds the same middleware stack as main.go: RequestID -> HTTP middleware ->
// strict dispatch -> inner handler.
func chiHandler(t *testing.T, publish func(events.Event) (events.Envelope, bool), operationID string, status int) http.Handler {
func chiHandler(t *testing.T, publish func(events.Event) (events.Envelope, bool), operationID string, status int, body func(ctx context.Context)) http.Handler {
t.Helper()
inner := fakeStrictHandler(operationID, status, []oapi.StrictMiddlewareFunc{TelemetryStrictMiddleware()})
inner := fakeStrictHandlerFunc(operationID, status, body)
telemetry := TelemetryHTTPMiddleware(publish)(inner)
return chiMiddleware.RequestID(telemetry)
}
2 changes: 2 additions & 0 deletions server/cmd/api/api/playwright.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.Exe
}, nil
}

RecordTelemetryCode(ctx, request.Body.Code)

timeout := 60 * time.Second
if request.Body.TimeoutSec != nil && *request.Body.TimeoutSec > 0 {
timeout = time.Duration(*request.Body.TimeoutSec) * time.Second
Expand Down
3 changes: 3 additions & 0 deletions server/cmd/api/api/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ func categoryFields(b *oapi.BrowserTelemetryCategoriesConfig) []categoryField {
{events.Page, b.Page},
{events.Interaction, b.Interaction},
{events.Control, b.Control},
{events.Platform, b.Platform},
{events.Connection, b.Connection},
{events.System, b.System},
{events.Screenshot, b.Screenshot},
Expand Down Expand Up @@ -332,6 +333,7 @@ func disabledConfig() oapi.BrowserTelemetryConfig {
Page: off(),
Interaction: off(),
Control: off(),
Platform: off(),
Connection: off(),
System: off(),
Screenshot: off(),
Expand Down Expand Up @@ -363,6 +365,7 @@ func telemetryConfigToOAPI(cfg telemetry.TelemetryConfig) oapi.BrowserTelemetryC
Page: enabled(events.Page),
Interaction: enabled(events.Interaction),
Control: enabled(events.Control),
Platform: enabled(events.Platform),
Connection: enabled(events.Connection),
System: enabled(events.System),
Screenshot: enabled(events.Screenshot),
Expand Down
1 change: 1 addition & 0 deletions server/cmd/api/api/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func allCategoriesDisabled() *oapi.BrowserTelemetryCategoriesConfig {
Page: off(),
Interaction: off(),
Control: off(),
Platform: off(),
Connection: off(),
System: off(),
Screenshot: off(),
Expand Down
7 changes: 4 additions & 3 deletions server/e2e/e2e_otlp_storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ func (c *otlpMockCollector) snapshot() (auths, instanceName, eventNames []string
}

// enableControlExport turns on the control category (so api_call events flow)
// and OTLP export, then makes a few API calls to generate exportable events.
// and OTLP export, then makes a few browser-control calls to generate
// exportable events.
func enableControlExport(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses) {
t.Helper()
tr := true
Expand All @@ -113,9 +114,9 @@ func enableControlExport(t *testing.T, ctx context.Context, client *instanceoapi
})
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode(), "put telemetry: %s", string(resp.Body))
// Each API call emits an api_call (control) event, which OTLP exports.
// Browser-control calls emit api_call (control) events, which OTLP exports.
for i := 0; i < 3; i++ {
_, _ = client.GetTelemetryWithResponse(ctx)
_, _ = client.TakeScreenshotWithResponse(ctx, instanceoapi.TakeScreenshotJSONRequestBody{})
time.Sleep(50 * time.Millisecond)
}
}
Expand Down
Loading
Loading