Skip to content
Open
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
10 changes: 10 additions & 0 deletions .github/workflows/server-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ jobs:
cache: true
cache-dependency-path: server/go.sum

# categorygen's checks (unclassified route, category that isn't control or
# platform, classified route with no handler) only run when the generator
# does, and its only other caller is `make oapi-generate`, which needs the
# npm down-converter. Running it here enforces them on every change.
- name: Check generated event categories are current
run: |
go generate ./lib/events/...
git diff --exit-code -- lib/events/category_gen.go
working-directory: server

- name: Run server unit tests
run: make test-unit
working-directory: server
Expand Down
44 changes: 37 additions & 7 deletions server/cmd/api/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ type telemetryCtxKey struct{}

type telemetryRequestCtx struct {
operationID string
code string
}

// RecordTelemetryCode attaches code submitted with the request to its api_call
// event, capped like every other captured string. 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
}
tc.code = events.TruncateCaptured(code, events.CapturedFieldCap)
}

// Process-wide toggle for the api_call middleware. Flipped by
Expand All @@ -35,9 +47,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 +70,38 @@ 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,
Type: eventType,
Category: category,
Source: oapi.BrowserEventSource{Kind: oapi.KernelApi},
Data: data,
})
})
}
}

// 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
113 changes: 100 additions & 13 deletions server/cmd/api/api/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"unicode/utf8"

chiMiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -37,21 +39,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 +74,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 +92,101 @@ 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)
// BrowserPlatformApiCallEventData has no code field, so the key must be
// absent rather than null for the payload to match the published schema.
assert.NotContains(t, string(captured[0].Data), `"code"`)
}

// 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", events.CapturedFieldCap-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)
var data struct {
Code string `json:"code"`
}
require.NoError(t, json.Unmarshal(captured[0].Data, &data))
assert.True(t, strings.HasSuffix(data.Code, events.TruncatedSuffix), "clipped code is not marked: %q", data.Code[max(0, len(data.Code)-40):])
assert.LessOrEqual(t, len(data.Code), events.CapturedFieldCap)
assert.True(t, utf8.ValidString(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 +218,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 +229,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
16 changes: 10 additions & 6 deletions server/cmd/api/api/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,14 @@ func (s *ApiService) PatchTelemetry(ctx context.Context, req oapi.PatchTelemetry
}

// reconcileTelemetryState reconciles the CDP collector and the api_call
// (control) middleware to the desired category set. The collector runs iff a
// CDP category is captured; the middleware emits iff the control category is.
// Callers commit the session config first so the filter is live before the
// collector emits; this returns an error only when the collector fails to
// start, leaving the caller to roll back.
// middleware to the desired category set. The collector runs iff a CDP category
// is captured; the middleware emits iff control or platform is, since it is the
// sole producer of both api_call and platform_api_call. Callers commit the
// session config first so the filter is live before the collector emits; this
// returns an error only when the collector fails to start, leaving the caller to
// roll back.
func (s *ApiService) reconcileTelemetryState(cats []oapi.TelemetryEventCategory) error {
if containsCategory(cats, events.Control) {
if containsCategory(cats, events.Control) || containsCategory(cats, events.Platform) {
EnableTelemetryMiddleware()
} else {
DisableTelemetryMiddleware()
Expand Down Expand Up @@ -221,6 +222,7 @@ func categoryFields(b *oapi.BrowserTelemetryCategoriesConfig) []categoryField {
{events.Page, b.Page},
{events.Interaction, b.Interaction},
{events.Control, b.Control},
{events.Platform, b.Platform},
Comment thread
cursor[bot] marked this conversation as resolved.
{events.Connection, b.Connection},
{events.System, b.System},
{events.Screenshot, b.Screenshot},
Expand Down Expand Up @@ -332,6 +334,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 +366,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
42 changes: 42 additions & 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 Expand Up @@ -230,6 +231,47 @@ func TestTelemetryHandlersDriveMiddlewareToggle(t *testing.T) {
assert.False(t, TelemetryMiddlewareEnabled(), "all-disabled PUT should leave middleware off")
}

// The middleware is the sole producer of platform_api_call as well as api_call,
// so a reader who migrates from control to platform must still get events.
func TestTelemetryHandlersEnableMiddlewareForPlatformOnly(t *testing.T) {
ctx := context.Background()
t.Cleanup(DisableTelemetryMiddleware)

svc := newTestService(t, newMockRecordManager())

DisableTelemetryMiddleware()
tr, f := true, false
_, err := svc.PutTelemetry(ctx, oapi.PutTelemetryRequestObject{
Body: &oapi.BrowserTelemetryConfig{
Browser: &oapi.BrowserTelemetryCategoriesConfig{
Platform: &oapi.BrowserTelemetryCategoryConfig{Enabled: &tr},
},
},
})
require.NoError(t, err)
assert.True(t, TelemetryMiddlewareEnabled(), "PUT with platform=true should enable middleware")

_, err = svc.PatchTelemetry(ctx, oapi.PatchTelemetryRequestObject{
Body: &oapi.BrowserTelemetryConfig{
Browser: &oapi.BrowserTelemetryCategoriesConfig{
Control: &oapi.BrowserTelemetryCategoryConfig{Enabled: &f},
},
},
})
require.NoError(t, err)
assert.True(t, TelemetryMiddlewareEnabled(), "dropping control should leave the middleware on for platform")

_, err = svc.PatchTelemetry(ctx, oapi.PatchTelemetryRequestObject{
Body: &oapi.BrowserTelemetryConfig{
Browser: &oapi.BrowserTelemetryCategoriesConfig{
Platform: &oapi.BrowserTelemetryCategoryConfig{Enabled: &f},
},
},
})
require.NoError(t, err)
assert.False(t, TelemetryMiddlewareEnabled(), "dropping platform too should disable middleware")
}

func TestGetTelemetry(t *testing.T) {
ctx := context.Background()

Expand Down
Loading
Loading