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
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
110 changes: 97 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,98 @@ 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", 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 +215,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 +226,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},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Platform-only config disables middleware

High Severity

The TelemetryHTTPMiddleware is currently only enabled when the control telemetry category is active. Since platform_api_call events, now part of the platform category, rely on this middleware, enabling platform without control means these events won't be collected or published.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5659a6a. Configure here.

{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
2 changes: 1 addition & 1 deletion server/lib/cdpmonitor/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ func (m *Monitor) fetchResponseBody(ctx context.Context, requestID, sessionID st
}
body = string(decoded)
}
return truncateBody(body, bodyCapFor(state.mimeType))
return events.TruncateCaptured(body, bodyCapFor(state.mimeType))
}

func (m *Monitor) handleLoadingFailed(p cdpNetworkLoadingFailedParams, sessionID string) {
Expand Down
35 changes: 3 additions & 32 deletions server/lib/cdpmonitor/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package cdpmonitor
import (
"encoding/json"
"strings"
"unicode/utf8"

"github.com/kernel/kernel-images/server/lib/events"
oapi "github.com/kernel/kernel-images/server/lib/oapi"
)

Expand Down Expand Up @@ -97,8 +97,8 @@ var structuredPrefixes = []string{
// Vendor types with +json, +xml, or +csv suffixes are also treated as structured,
// matching the allow-list in isCapturedMIME.
func bodyCapFor(mime string) int {
const fullCap = 8 * 1024
const contextCap = 4 * 1024
const fullCap = events.CapturedFieldCap
const contextCap = events.CapturedFieldCap / 2
for _, p := range structuredPrefixes {
if strings.HasPrefix(mime, p) {
return fullCap
Expand All @@ -112,32 +112,3 @@ func bodyCapFor(mime string) int {
}
return contextCap
}

const truncatedSuffix = "...[truncated]"

// truncateBody caps body at the given limit on a valid UTF-8 boundary.
// The result never splits a multi-byte rune. A truncation suffix is appended
// when the body is cut so callers can distinguish truncated from full content.
func truncateBody(body string, maxBody int) string {
if len(body) <= maxBody {
return body
}
if maxBody <= 0 {
return ""
}
// Reserve room for the truncation suffix within the limit.
cutAt := maxBody - len(truncatedSuffix)
if cutAt <= 0 {
return truncatedSuffix[:maxBody]
}
// Walk forward through complete runes, stopping before we exceed cutAt.
end := 0
for end < cutAt {
_, size := utf8.DecodeRuneInString(body[end:])
if end+size > cutAt {
break
}
end += size
}
return body[:end] + truncatedSuffix
}
Loading
Loading