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
62 changes: 55 additions & 7 deletions server/cmd/api/api/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,10 @@ func (s *ApiService) PatchTelemetry(ctx context.Context, req oapi.PatchTelemetry
return oapi.PatchTelemetry404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "telemetry is not configured"}}, nil
}

// Nothing to merge when neither the category block nor a sink toggle is
// present; skip the reconcile and echo current state, as before.
if req.Body == nil || (req.Body.Browser == nil && req.Body.Export == nil && req.Body.Storage == nil) {
// Nothing to merge when neither the category block, a sink toggle, nor the
// browser session identity is present; skip the reconcile and echo current
// state, as before.
if req.Body == nil || (req.Body.Browser == nil && req.Body.Export == nil && req.Body.Storage == nil && req.Body.BrowserSession == nil) {
return oapi.PatchTelemetry200JSONResponse(s.buildTelemetryResponse()), nil
}

Expand Down Expand Up @@ -343,11 +344,18 @@ func containsCategory(cats []oapi.TelemetryEventCategory, target oapi.TelemetryE
func telemetryConfigFromOAPI(cfg *oapi.BrowserTelemetryConfig) (telemetry.TelemetryConfig, bool, error) {
exportOTLP := exportOTLPFromOAPI(cfg)
storeS2 := storeS2FromOAPI(cfg)
sessionID, sessionName := browserSessionFromOAPI(cfg)
if cfg == nil || cfg.Browser == nil {
// No per-category settings: resolve to the explicit default set so the
// effective categories are known before the collector is reconciled.
cats := append([]oapi.TelemetryEventCategory(nil), events.DefaultCategories...)
return telemetry.TelemetryConfig{Categories: cats, ExportOTLP: exportOTLP, StoreS2: storeS2}, false, nil
return telemetry.TelemetryConfig{
Categories: cats,
ExportOTLP: exportOTLP,
StoreS2: storeS2,
BrowserSessionID: sessionID,
BrowserSessionName: sessionName,
}, false, nil
}

cats := make([]oapi.TelemetryEventCategory, 0, len(events.UserCategories))
Expand All @@ -366,9 +374,20 @@ func telemetryConfigFromOAPI(cfg *oapi.BrowserTelemetryConfig) (telemetry.Teleme
ExportOTLP: exportOTLP,
StoreS2: storeS2,
ExcludedCdpMethods: excludedCdpMethodsFromOAPI(cfg),
BrowserSessionID: sessionID,
BrowserSessionName: sessionName,
}, false, nil
}

// browserSessionFromOAPI reads the browser session identity from a config. An
// omitted block or field is empty, so a PUT without it clears the identity.
func browserSessionFromOAPI(cfg *oapi.BrowserTelemetryConfig) (id, name string) {
if cfg == nil || cfg.BrowserSession == nil {
return "", ""
}
return lo.FromPtr(cfg.BrowserSession.Id), lo.FromPtr(cfg.BrowserSession.Name)
}

// exportOTLPFromOAPI reads the OTLP export toggle from a config, defaulting to
// false (off) when the export block is omitted.
func exportOTLPFromOAPI(cfg *oapi.BrowserTelemetryConfig) bool {
Expand Down Expand Up @@ -431,14 +450,33 @@ func mergeTelemetryConfig(current telemetry.TelemetryConfig, patch *oapi.Browser
excluded = patched
}

// The browser session identity merges per field: an omitted field is
// unchanged, an empty one clears it.
sessionID, sessionName := current.BrowserSessionID, current.BrowserSessionName
if patch.BrowserSession != nil {
if patch.BrowserSession.Id != nil {
sessionID = *patch.BrowserSession.Id
}
if patch.BrowserSession.Name != nil {
sessionName = *patch.BrowserSession.Name
}
}

if len(active) == 0 {
return telemetry.TelemetryConfig{StoreS2: storeS2}, true
}
cats := make([]oapi.TelemetryEventCategory, 0, len(active))
for c := range active {
cats = append(cats, c)
}
return telemetry.TelemetryConfig{Categories: cats, ExportOTLP: exportOTLP, StoreS2: storeS2, ExcludedCdpMethods: excluded}, false
return telemetry.TelemetryConfig{
Categories: cats,
ExportOTLP: exportOTLP,
StoreS2: storeS2,
ExcludedCdpMethods: excluded,
BrowserSessionID: sessionID,
BrowserSessionName: sessionName,
}, false
}

// disabledConfig returns a BrowserTelemetryConfig with every configurable
Expand Down Expand Up @@ -502,7 +540,17 @@ func telemetryConfigToOAPI(cfg telemetry.TelemetryConfig) oapi.BrowserTelemetryC
Screenshot: enabled(events.Screenshot),
Captcha: enabled(events.Captcha),
},
Export: exportConfigToOAPI(cfg.ExportOTLP),
Storage: storageConfigToOAPI(cfg.StoreS2),
Export: exportConfigToOAPI(cfg.ExportOTLP),
Storage: storageConfigToOAPI(cfg.StoreS2),
BrowserSession: browserSessionToOAPI(cfg.BrowserSessionID, cfg.BrowserSessionName),
}
}

// browserSessionToOAPI renders the browser session identity for API responses,
// omitting the block when neither field is set.
func browserSessionToOAPI(id, name string) *oapi.BrowserTelemetrySessionConfig {
if id == "" && name == "" {
return nil
}
return &oapi.BrowserTelemetrySessionConfig{Id: lo.EmptyableToPtr(id), Name: lo.EmptyableToPtr(name)}
}
95 changes: 95 additions & 0 deletions server/cmd/api/api/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,101 @@ func TestCdpExcludedMethodsRoundTrip(t *testing.T) {
})
}

func TestTelemetryBrowserSession(t *testing.T) {
ctx := context.Background()
withSession := func(id, name string) *oapi.BrowserTelemetryConfig {
return &oapi.BrowserTelemetryConfig{
Browser: &oapi.BrowserTelemetryCategoriesConfig{Console: &oapi.BrowserTelemetryCategoryConfig{Enabled: lo.ToPtr(true)}},
BrowserSession: &oapi.BrowserTelemetrySessionConfig{Id: lo.EmptyableToPtr(id), Name: lo.EmptyableToPtr(name)},
}
}
// publishMetadata publishes one console event and returns the metadata it
// was stamped with.
publishMetadata := func(t *testing.T, svc *ApiService) map[string]string {
t.Helper()
env, ok := svc.telemetrySession.Publish(events.Event{
Type: "console_log",
Category: events.Console,
Source: oapi.BrowserEventSource{Kind: oapi.Cdp},
})
require.True(t, ok)
require.NotNil(t, env.Event.Source.Metadata)
return *env.Event.Source.Metadata
}

t.Run("put stamps and echoes the browser session", func(t *testing.T) {
svc := newTestService(t, newMockRecordManager())
resp, err := svc.PutTelemetry(ctx, oapi.PutTelemetryRequestObject{Body: withSession("browser-1", "checkout")})
require.NoError(t, err)
created := resp.(oapi.PutTelemetry201JSONResponse)
require.NotNil(t, created.Config.BrowserSession)
assert.Equal(t, "browser-1", *created.Config.BrowserSession.Id)
assert.Equal(t, "checkout", *created.Config.BrowserSession.Name)

meta := publishMetadata(t, svc)
assert.Equal(t, "browser-1", meta[telemetry.MetadataKeySessionID])
assert.Equal(t, "checkout", meta[telemetry.MetadataKeySessionName])
})

t.Run("put on a running session switches to the new browser session", func(t *testing.T) {
svc := newTestService(t, newMockRecordManager())
_, err := svc.PutTelemetry(ctx, oapi.PutTelemetryRequestObject{Body: withSession("browser-1", "first")})
require.NoError(t, err)
telemetrySessionID := publishMetadata(t, svc)[telemetry.MetadataKeyTelemetrySessionID]

resp, err := svc.PutTelemetry(ctx, oapi.PutTelemetryRequestObject{Body: withSession("browser-2", "")})
require.NoError(t, err)
require.IsType(t, oapi.PutTelemetry200JSONResponse{}, resp)

meta := publishMetadata(t, svc)
assert.Equal(t, "browser-2", meta[telemetry.MetadataKeySessionID])
assert.NotContains(t, meta, telemetry.MetadataKeySessionName, "a PUT replaces the identity wholesale")
assert.Equal(t, telemetrySessionID, meta[telemetry.MetadataKeyTelemetrySessionID])
})

t.Run("put without the block clears it", func(t *testing.T) {
svc := newTestService(t, newMockRecordManager())
_, err := svc.PutTelemetry(ctx, oapi.PutTelemetryRequestObject{Body: withSession("browser-1", "first")})
require.NoError(t, err)

resp, err := svc.PutTelemetry(ctx, oapi.PutTelemetryRequestObject{Body: withSession("", "")})
require.NoError(t, err)
assert.Nil(t, resp.(oapi.PutTelemetry200JSONResponse).Config.BrowserSession)

meta := publishMetadata(t, svc)
assert.NotContains(t, meta, telemetry.MetadataKeySessionID)
assert.NotContains(t, meta, telemetry.MetadataKeySessionName)
})

t.Run("patch merges per field and leaves capture alone", func(t *testing.T) {
svc := newTestService(t, newMockRecordManager())
_, err := svc.PutTelemetry(ctx, oapi.PutTelemetryRequestObject{Body: withSession("browser-1", "first")})
require.NoError(t, err)
before := svc.telemetrySession.Config()

resp, err := svc.PatchTelemetry(ctx, oapi.PatchTelemetryRequestObject{Body: &oapi.BrowserTelemetryConfig{
BrowserSession: &oapi.BrowserTelemetrySessionConfig{Name: lo.ToPtr("renamed")},
}})
require.NoError(t, err)
patched := resp.(oapi.PatchTelemetry200JSONResponse)
assert.Equal(t, "browser-1", *patched.Config.BrowserSession.Id)
assert.Equal(t, "renamed", *patched.Config.BrowserSession.Name)
assert.ElementsMatch(t, before.Categories, svc.telemetrySession.Config().Categories)

meta := publishMetadata(t, svc)
assert.Equal(t, "browser-1", meta[telemetry.MetadataKeySessionID])
assert.Equal(t, "renamed", meta[telemetry.MetadataKeySessionName])

_, err = svc.PatchTelemetry(ctx, oapi.PatchTelemetryRequestObject{Body: &oapi.BrowserTelemetryConfig{
BrowserSession: &oapi.BrowserTelemetrySessionConfig{Name: lo.ToPtr("")},
}})
require.NoError(t, err)
meta = publishMetadata(t, svc)
assert.Equal(t, "browser-1", meta[telemetry.MetadataKeySessionID])
assert.NotContains(t, meta, telemetry.MetadataKeySessionName, "an empty name clears it")
})
}

// dropped_events was added to TelemetryState after it shipped, so it stays
// optional: a response from an image that predates it must still decode, and
// an old client's control block must still be a valid request.
Expand Down
2 changes: 2 additions & 0 deletions server/lib/cdpmonitor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ Every event arrives as an `Envelope`:
| `event.category` | string | Emitted by this monitor: `console`, `network`, `page`, `interaction`, `screenshot`, `monitor` (collector health). |
| `event.truncated` | bool | `true` if `data` was nulled to fit the 1 MB pipeline limit. |
| `event.source.metadata.telemetry_session_id` | string | Pipeline-assigned ID for the telemetry session, stamped by the telemetry layer. |
| `event.source.metadata.session_id` | string | Browser session that owns the capture, stamped by the telemetry layer when the telemetry config carries `browser_session.id`. |
| `event.source.metadata.session_name` | string | That browser session's name, stamped when the config carries `browser_session.name`. |

### Source object

Expand Down
6 changes: 3 additions & 3 deletions server/lib/events/otlpconvert.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ func toLogRecord(env Envelope) log.Record {
if ev.Truncated {
attrs = append(attrs, log.Bool("kernel.truncated", true))
}
// Producer metadata keys (telemetry_session_id, cdp ids) pass through under
// the kernel. prefix as-is: they are producer snake_case, unlike the dotted
// converter-owned keys above.
// Producer metadata keys (telemetry_session_id, the browser session_id and
// session_name, cdp ids) pass through under the kernel. prefix as-is: they
// are producer snake_case, unlike the dotted converter-owned keys above.
if ev.Source.Metadata != nil {
for k, v := range *ev.Source.Metadata {
attrs = append(attrs, log.String("kernel."+k, v))
Expand Down
4 changes: 4 additions & 0 deletions server/lib/events/otlpconvert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func attrsOf(rec log.Record) map[string]log.Value {
func TestToLogRecord_CoreFields(t *testing.T) {
meta := map[string]string{
"telemetry_session_id": "cs_abc",
"session_id": "browser_abc",
"session_name": "checkout",
"cdp_session_id": "s1",
"target_id": "t1",
}
Expand Down Expand Up @@ -56,6 +58,8 @@ func TestToLogRecord_CoreFields(t *testing.T) {
assert.Equal(t, "cdp", attrs["kernel.source.kind"].AsString())
assert.Equal(t, "Network.loadingFinished", attrs["kernel.source.event"].AsString())
assert.Equal(t, "cs_abc", attrs["kernel.telemetry_session_id"].AsString())
assert.Equal(t, "browser_abc", attrs["kernel.session_id"].AsString())
assert.Equal(t, "checkout", attrs["kernel.session_name"].AsString())
assert.Equal(t, "s1", attrs["kernel.cdp_session_id"].AsString())
assert.Equal(t, "t1", attrs["kernel.target_id"].AsString())
// High-value network fields promoted to queryable attributes.
Expand Down
Loading
Loading