Skip to content

Commit b6c3a4f

Browse files
heiskrCopilotCopilot
authored
Add TableInteractionEvent analytics for secret scanning table (#61491)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: heiskr <1221423+heiskr@users.noreply.github.com>
1 parent 7640df6 commit b6c3a4f

6 files changed

Lines changed: 200 additions & 32 deletions

File tree

data/ui.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ secret_scanning:
273273
filter_all: All
274274
filter_yes: 'Yes'
275275
filter_no: 'No'
276+
clear_filters: Clear filters
277+
clear_filters_aria_label: Clear all filters and search
276278
showing_patterns: 'Showing {filtered} of {total} patterns'
277279
column_provider: Provider
278280
column_secret: Secret

src/events/lib/schema.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,39 @@ const preference = {
646646
},
647647
}
648648

649+
const tableInteraction = {
650+
type: 'object',
651+
additionalProperties: false,
652+
required: ['type', 'context', 'table_interaction_name', 'table_interaction_type'],
653+
properties: {
654+
context,
655+
type: {
656+
type: 'string',
657+
pattern: '^tableInteraction$',
658+
},
659+
table_interaction_name: {
660+
type: 'string',
661+
description:
662+
'Identifier for the table being interacted with (e.g. "secret-scanning-patterns").',
663+
},
664+
table_interaction_type: {
665+
type: 'string',
666+
enum: ['search', 'filter', 'sort', 'paginate', 'reset'],
667+
description: 'The kind of interaction the user performed with the table.',
668+
},
669+
table_interaction_field_name: {
670+
type: 'string',
671+
description:
672+
'The field/column the interaction targeted (e.g. "pushProtection"). Omitted for whole-table actions.',
673+
},
674+
table_interaction_field_value: {
675+
type: 'string',
676+
description:
677+
'The value applied to the field (e.g. the filter value, search query, sort direction, or page number).',
678+
},
679+
},
680+
}
681+
649682
const validation = {
650683
type: 'object',
651684
additionalProperties: false,
@@ -682,6 +715,7 @@ export const schemas = {
682715
clipboard,
683716
print,
684717
preference,
718+
tableInteraction,
685719
validation,
686720
}
687721

@@ -699,6 +733,7 @@ export const hydroNames = {
699733
clipboard: 'docs.v0.ClipboardEvent',
700734
print: 'docs.v0.PrintEvent',
701735
preference: 'docs.v0.PreferenceEvent',
736+
tableInteraction: 'docs.v0.TableInteractionEvent',
702737
validation: 'docs.v0.ValidationEvent',
703738
} as Record<keyof typeof schemas, string>
704739

src/events/tests/middleware.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,4 +210,38 @@ describe('POST /events', () => {
210210
})
211211
expect(statusCode).toBe(400)
212212
})
213+
214+
test('should accept a tableInteraction filter event', async () => {
215+
const { statusCode } = await checkEvent({
216+
type: 'tableInteraction',
217+
context: pageExample.context,
218+
table_interaction_name: 'secret-scanning-patterns',
219+
table_interaction_type: 'filter',
220+
table_interaction_field_name: 'pushProtection',
221+
table_interaction_field_value: 'yes',
222+
})
223+
expect(statusCode).toBe(200)
224+
})
225+
226+
test('should accept a tableInteraction event without optional fields', async () => {
227+
const { statusCode } = await checkEvent({
228+
type: 'tableInteraction',
229+
context: pageExample.context,
230+
table_interaction_name: 'secret-scanning-patterns',
231+
table_interaction_type: 'reset',
232+
})
233+
expect(statusCode).toBe(200)
234+
})
235+
236+
test('should reject a tableInteraction event with an invalid interaction type', async () => {
237+
const { statusCode } = await checkEvent({
238+
type: 'tableInteraction',
239+
context: pageExample.context,
240+
table_interaction_name: 'secret-scanning-patterns',
241+
table_interaction_type: 'not-a-valid-type',
242+
table_interaction_field_name: 'pushProtection',
243+
table_interaction_field_value: 'yes',
244+
})
245+
expect(statusCode).toBe(400)
246+
})
213247
})

src/events/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export enum EventType {
1212
preference = 'preference',
1313
clipboard = 'clipboard',
1414
print = 'print',
15+
tableInteraction = 'tableInteraction',
1516
}
1617

1718
export type EventProps = {
@@ -135,4 +136,10 @@ export type EventPropsByType = {
135136
survey_comment_language?: string
136137
survey_connected_event_id?: string
137138
}
139+
[EventType.tableInteraction]: {
140+
table_interaction_name: string
141+
table_interaction_type: 'search' | 'filter' | 'sort' | 'paginate' | 'reset'
142+
table_interaction_field_name?: string
143+
table_interaction_field_value?: string
144+
}
138145
}

src/fixtures/fixtures/data/ui.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ secret_scanning:
273273
filter_all: All
274274
filter_yes: 'Yes'
275275
filter_no: 'No'
276+
clear_filters: Clear filters
277+
clear_filters_aria_label: Clear all filters and search
276278
showing_patterns: 'Showing {filtered} of {total} patterns'
277279
column_provider: Provider
278280
column_secret: Secret

src/secret-scanning/components/SecretScanningTable.tsx

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
1-
import React, { useState, useMemo } from 'react'
1+
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'
22
import { DataTable, Table } from '@primer/react/experimental'
3-
import { TextInput, ActionMenu, ActionList, Pagination } from '@primer/react'
3+
import { TextInput, ActionMenu, ActionList, Pagination, Button } from '@primer/react'
4+
import debounce from 'lodash/debounce'
45
import { useTranslation } from '@/languages/components/useTranslation'
6+
import { sendEvent } from '@/events/components/events'
7+
import { EventType } from '@/events/types'
8+
import { sanitizeSearchQuery } from '@/search/lib/sanitize-search-query'
59
import type { SecretScanningData } from '@/types'
610

711
const PAGE_SIZE = 25
812

13+
// Identifies this table in the docs.v0.TableInteractionEvent analytics.
14+
const TABLE_INTERACTION_NAME = 'secret-scanning-patterns'
15+
16+
// Maps DataTable column ids to the canonical analytics field name so that a
17+
// filter and a sort on the same column report the same
18+
// table_interaction_field_name. Filter keys already use these canonical names.
19+
const COLUMN_FIELD_NAMES: Record<string, string> = {
20+
provider: 'provider',
21+
supportedSecret: 'secret',
22+
isPublic: 'partnerAlert',
23+
isPrivateWithGhas: 'userAlert',
24+
hasPushProtection: 'pushProtection',
25+
hasValidityCheck: 'validityCheck',
26+
hasExtendedMetadata: 'metadata',
27+
base64Supported: 'base64',
28+
}
29+
930
type SecretScanningRow = SecretScanningData & { id: string }
1031

1132
type FilterState = {
@@ -17,20 +38,81 @@ type FilterState = {
1738
base64: 'all' | 'yes' | 'no'
1839
}
1940

41+
type FilterKey = Exclude<keyof FilterState, 'search'>
42+
43+
const DEFAULT_FILTERS: FilterState = {
44+
search: '',
45+
pushProtection: 'all',
46+
validityCheck: 'all',
47+
partnerAlert: 'all',
48+
metadata: 'all',
49+
base64: 'all',
50+
}
51+
52+
type TableInteractionType = 'search' | 'filter' | 'sort' | 'paginate' | 'reset'
53+
2054
export function SecretScanningTable({ data }: { data: SecretScanningData[] }) {
2155
const { t } = useTranslation('secret_scanning')
22-
const [filters, setFilters] = useState<FilterState>({
23-
search: '',
24-
pushProtection: 'all',
25-
validityCheck: 'all',
26-
partnerAlert: 'all',
27-
metadata: 'all',
28-
base64: 'all',
29-
})
56+
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS)
3057
const [currentPage, setCurrentPage] = useState(1)
3158
const [sortColumn, setSortColumn] = useState<string | undefined>(undefined)
3259
const [sortDirection, setSortDirection] = useState<'ASC' | 'DESC'>('ASC')
3360

61+
// Emit a TableInteractionEvent for analytics (github/docs-engineering#6593).
62+
const trackInteraction = useCallback(
63+
(interactionType: TableInteractionType, fieldName?: string, fieldValue?: string) => {
64+
sendEvent({
65+
type: EventType.tableInteraction,
66+
table_interaction_name: TABLE_INTERACTION_NAME,
67+
table_interaction_type: interactionType,
68+
table_interaction_field_name: fieldName,
69+
table_interaction_field_value: fieldValue,
70+
})
71+
},
72+
[],
73+
)
74+
75+
// Debounce search tracking so we record the settled query, not every keystroke.
76+
const debouncedTrackSearchRef = useRef<ReturnType<typeof debounce> | null>(null)
77+
useEffect(() => {
78+
debouncedTrackSearchRef.current = debounce((query: string) => {
79+
// Sanitize before logging: users may paste a real secret into this
80+
// table's search to check support, and the query is sent to analytics.
81+
trackInteraction('search', 'search', sanitizeSearchQuery(query))
82+
}, 500)
83+
return () => {
84+
debouncedTrackSearchRef.current?.flush()
85+
debouncedTrackSearchRef.current?.cancel()
86+
}
87+
}, [trackInteraction])
88+
89+
const handleFilterChange = useCallback(
90+
(field: FilterKey, value: 'all' | 'yes' | 'no') => {
91+
setFilters((f) => ({ ...f, [field]: value }))
92+
setCurrentPage(1)
93+
trackInteraction('filter', field, value)
94+
},
95+
[trackInteraction],
96+
)
97+
98+
const handleReset = useCallback(() => {
99+
setFilters(DEFAULT_FILTERS)
100+
setCurrentPage(1)
101+
setSortColumn(undefined)
102+
setSortDirection('ASC')
103+
debouncedTrackSearchRef.current?.cancel()
104+
trackInteraction('reset')
105+
}, [trackInteraction])
106+
107+
const hasActiveFilters =
108+
filters.search !== '' ||
109+
filters.pushProtection !== 'all' ||
110+
filters.validityCheck !== 'all' ||
111+
filters.partnerAlert !== 'all' ||
112+
filters.metadata !== 'all' ||
113+
filters.base64 !== 'all' ||
114+
sortColumn !== undefined
115+
34116
// Add stable IDs once based on original data order
35117
const dataWithIds: SecretScanningRow[] = useMemo(() => {
36118
return data.map((entry, i) => ({ ...entry, id: `${entry.secretType}-${i}` }))
@@ -92,51 +174,49 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) {
92174
<FilterDropdown
93175
label={t('filter_push_protection')}
94176
value={filters.pushProtection}
95-
onChange={(v) => {
96-
setFilters((f) => ({ ...f, pushProtection: v }))
97-
setCurrentPage(1)
98-
}}
177+
onChange={(v) => handleFilterChange('pushProtection', v)}
99178
/>
100179
<FilterDropdown
101180
label={t('filter_validity_check')}
102181
value={filters.validityCheck}
103-
onChange={(v) => {
104-
setFilters((f) => ({ ...f, validityCheck: v }))
105-
setCurrentPage(1)
106-
}}
182+
onChange={(v) => handleFilterChange('validityCheck', v)}
107183
/>
108184
<FilterDropdown
109185
label={t('filter_partner_alert')}
110186
value={filters.partnerAlert}
111-
onChange={(v) => {
112-
setFilters((f) => ({ ...f, partnerAlert: v }))
113-
setCurrentPage(1)
114-
}}
187+
onChange={(v) => handleFilterChange('partnerAlert', v)}
115188
/>
116189
<FilterDropdown
117190
label={t('filter_metadata')}
118191
value={filters.metadata}
119-
onChange={(v) => {
120-
setFilters((f) => ({ ...f, metadata: v }))
121-
setCurrentPage(1)
122-
}}
192+
onChange={(v) => handleFilterChange('metadata', v)}
123193
/>
124194
<FilterDropdown
125195
label={t('filter_base64')}
126196
value={filters.base64}
127-
onChange={(v) => {
128-
setFilters((f) => ({ ...f, base64: v }))
129-
setCurrentPage(1)
130-
}}
197+
onChange={(v) => handleFilterChange('base64', v)}
131198
/>
199+
{hasActiveFilters && (
200+
<Button
201+
variant="invisible"
202+
size="small"
203+
onClick={handleReset}
204+
aria-label={t('clear_filters_aria_label')}
205+
>
206+
{t('clear_filters')}
207+
</Button>
208+
)}
132209
</div>
133210
<TextInput
134211
aria-label={t('search_aria_label')}
135212
placeholder={t('search_placeholder')}
136213
value={filters.search}
137214
onChange={(e) => {
138-
setFilters((f) => ({ ...f, search: e.target.value }))
215+
const value = e.target.value
216+
setFilters((f) => ({ ...f, search: value }))
139217
setCurrentPage(1)
218+
if (value.trim()) debouncedTrackSearchRef.current?.(value)
219+
else debouncedTrackSearchRef.current?.cancel()
140220
}}
141221
/>
142222
</div>
@@ -168,6 +248,11 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) {
168248
setSortColumn(String(columnId))
169249
setSortDirection(direction)
170250
setCurrentPage(1)
251+
trackInteraction(
252+
'sort',
253+
COLUMN_FIELD_NAMES[String(columnId)] ?? String(columnId),
254+
direction,
255+
)
171256
}}
172257
columns={[
173258
{
@@ -280,7 +365,10 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) {
280365
aria-label={t('pagination_label')}
281366
pageCount={pageCount}
282367
currentPage={currentPage}
283-
onPageChange={(_e: React.MouseEvent, page: number) => setCurrentPage(page)}
368+
onPageChange={(_e: React.MouseEvent, page: number) => {
369+
setCurrentPage(page)
370+
trackInteraction('paginate', 'page', String(page))
371+
}}
284372
/>
285373
)}
286374
</div>

0 commit comments

Comments
 (0)