Skip to content

Commit a74b3cc

Browse files
authored
Integrate @github/hydro-analytics-client for cross-subdomain tracking (#59364)
1 parent 9666cc9 commit a74b3cc

File tree

8 files changed

+222
-1
lines changed

8 files changed

+222
-1
lines changed

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
157157
"dependencies": {
158158
"@elastic/elasticsearch": "8.19.1",
159159
"@github/failbot": "0.8.3",
160+
"@github/hydro-analytics-client": "^2.3.3",
160161
"@gr2m/gray-matter": "4.0.3-with-pr-137",
161162
"@horizon-rs/language-guesser": "0.1.1",
162163
"@octokit/graphql": "9.0.1",

src/events/components/events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isLoggedIn } from '@/frame/components/hooks/useHasAccount'
66
import { getExperimentVariationForContext } from './experiments/experiment'
77
import { EventType, EventPropsByType } from '../types'
88
import { isHeadless } from './is-headless'
9+
import { sendHydroAnalyticsEvent, getOctoClientId } from './hydro-analytics'
910

1011
const COOKIE_NAME = '_docs-events'
1112

@@ -114,6 +115,7 @@ export function sendEvent<T extends EventType>({
114115
content_type: getMetaContent('page-content-type'),
115116
status: Number(getMetaContent('status') || 0),
116117
is_logged_in: isLoggedIn(),
118+
octo_client_id: getOctoClientId(),
117119

118120
// Device information
119121
// os, os_version, browser, browser_version:
@@ -152,6 +154,9 @@ export function sendEvent<T extends EventType>({
152154

153155
queueEvent(body)
154156

157+
// Send events to hydro-analytics-client for cross-subdomain tracking
158+
sendHydroAnalyticsEvent(body)
159+
155160
if (type === EventType.exit) {
156161
flushQueue()
157162
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Integration with @github/hydro-analytics-client for cross-subdomain tracking.
3+
*
4+
* This sends events to collector.githubapp.com alongside our existing analytics.
5+
* The client auto-collects: page, title, client_id, referrer, user_agent,
6+
* screen_resolution, browser_resolution, browser_languages, pixel_ratio, timestamp, tz_seconds
7+
*
8+
* We send all other docs-specific context fields, including:
9+
* - path_language, path_version, path_product, path_article
10+
* - page_document_type, page_type, content_type
11+
* - color_mode_preference, is_logged_in, experiment_variation, is_headless
12+
* - event_id, page_event_id, octo_client_id
13+
* - Plus any event-specific properties (exit metrics, link_url, etc.)
14+
*
15+
* All functions are wrapped in try/catch to ensure that issues with the
16+
* hydro-analytics-client or collector don't affect our primary analytics.
17+
*/
18+
19+
import {
20+
AnalyticsClient,
21+
getOrCreateClientId as hydroGetOrCreateClientId,
22+
} from '@github/hydro-analytics-client'
23+
import { EventType } from '../types'
24+
25+
/**
26+
* Safe wrapper around hydro-analytics-client's getOrCreateClientId.
27+
* Returns undefined if the client fails for any reason.
28+
*/
29+
export function getOctoClientId(): string | undefined {
30+
try {
31+
return hydroGetOrCreateClientId()
32+
} catch (error) {
33+
console.log('hydro-analytics-client getOctoClientId error:', error)
34+
return undefined
35+
}
36+
}
37+
38+
const hydroClient = new AnalyticsClient({
39+
collectorUrl: 'https://collector.githubapp.com/docs/collect',
40+
clientId: getOctoClientId(),
41+
})
42+
43+
// Fields that hydro-analytics-client already collects automatically
44+
const AUTO_COLLECTED_FIELDS = new Set([
45+
'referrer',
46+
'user_agent',
47+
'viewport_width',
48+
'viewport_height',
49+
'screen_width',
50+
'screen_height',
51+
'pixel_ratio',
52+
'timezone',
53+
'user_language',
54+
'href',
55+
'title',
56+
])
57+
58+
/**
59+
* Flatten a nested event body into a single-level context object,
60+
* excluding fields that hydro-analytics-client already auto-collects.
61+
*/
62+
export function prepareData(body: Record<string, unknown>): {
63+
type: string
64+
context: Record<string, string>
65+
} {
66+
const { context: nestedContext, type, ...rest } = body
67+
const flattened = {
68+
...((nestedContext as Record<string, unknown>) || {}),
69+
...rest,
70+
}
71+
const context = Object.fromEntries(
72+
Object.entries(flattened)
73+
.filter(([, value]) => value != null)
74+
.filter(([key]) => !AUTO_COLLECTED_FIELDS.has(key))
75+
.map(([key, value]) => [key, String(value)]),
76+
)
77+
return { type: typeof type === 'string' ? type : 'unknown', context }
78+
}
79+
80+
/**
81+
* Send an event to hydro-analytics-client.
82+
* For page events, sends as a page view. For all other events, sends as a custom event.
83+
*
84+
* This is wrapped in try/catch to ensure that if the hydro collector is down
85+
* or errors, it doesn't affect our primary analytics pipeline.
86+
*/
87+
export function sendHydroAnalyticsEvent(body: Record<string, unknown>): void {
88+
try {
89+
const { type, context } = prepareData(body)
90+
if (type === EventType.page) {
91+
hydroClient.sendPageView(context)
92+
} else {
93+
hydroClient.sendEvent(type, context)
94+
}
95+
} catch (error) {
96+
console.log('hydro-analytics-client error:', error)
97+
}
98+
}

src/events/lib/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ const context = {
124124
type: 'boolean',
125125
description: 'The cookie value of staffonly',
126126
},
127+
octo_client_id: {
128+
type: 'string',
129+
description:
130+
'The _octo cookie client ID for cross-subdomain tracking with github.com analytics.',
131+
},
127132

128133
// Device information
129134
os: {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { prepareData } from '../components/hydro-analytics'
3+
4+
describe('prepareData', () => {
5+
test('flattens nested context into top level', () => {
6+
const body = {
7+
type: 'page',
8+
context: {
9+
event_id: '123',
10+
path_language: 'en',
11+
},
12+
}
13+
const result = prepareData(body)
14+
expect(result.type).toBe('page')
15+
expect(result.context.event_id).toBe('123')
16+
expect(result.context.path_language).toBe('en')
17+
})
18+
19+
test('includes top-level props alongside context', () => {
20+
const body = {
21+
type: 'exit',
22+
context: { event_id: '123' },
23+
exit_scroll_length: 0.75,
24+
}
25+
const result = prepareData(body)
26+
expect(result.type).toBe('exit')
27+
expect(result.context.event_id).toBe('123')
28+
expect(result.context.exit_scroll_length).toBe('0.75')
29+
})
30+
31+
test('filters out auto-collected fields', () => {
32+
const body = {
33+
type: 'page',
34+
context: {
35+
event_id: '123',
36+
referrer: 'https://google.com',
37+
user_agent: 'Mozilla/5.0',
38+
viewport_width: 1024,
39+
title: 'Test Page',
40+
path_language: 'en',
41+
},
42+
}
43+
const result = prepareData(body)
44+
expect(result.context.event_id).toBe('123')
45+
expect(result.context.path_language).toBe('en')
46+
expect(result.context.referrer).toBeUndefined()
47+
expect(result.context.user_agent).toBeUndefined()
48+
expect(result.context.viewport_width).toBeUndefined()
49+
expect(result.context.title).toBeUndefined()
50+
})
51+
52+
test('filters out null and undefined values', () => {
53+
const body = {
54+
type: 'page',
55+
context: {
56+
event_id: '123',
57+
path_language: null,
58+
path_version: undefined,
59+
path_product: 'actions',
60+
},
61+
}
62+
const result = prepareData(body)
63+
expect(result.context.event_id).toBe('123')
64+
expect(result.context.path_product).toBe('actions')
65+
expect(result.context.path_language).toBeUndefined()
66+
expect(result.context.path_version).toBeUndefined()
67+
})
68+
69+
test('converts all values to strings', () => {
70+
const body = {
71+
type: 'exit',
72+
context: {
73+
status: 200,
74+
is_logged_in: true,
75+
is_headless: false,
76+
},
77+
}
78+
const result = prepareData(body)
79+
expect(result.context.status).toBe('200')
80+
expect(result.context.is_logged_in).toBe('true')
81+
expect(result.context.is_headless).toBe('false')
82+
})
83+
84+
test('defaults type to unknown if not a string', () => {
85+
const body = {
86+
type: 123,
87+
context: { event_id: '123' },
88+
}
89+
const result = prepareData(body)
90+
expect(result.type).toBe('unknown')
91+
})
92+
93+
test('handles missing context gracefully', () => {
94+
const body = {
95+
type: 'page',
96+
exit_scroll_length: 0.5,
97+
}
98+
const result = prepareData(body)
99+
expect(result.type).toBe('page')
100+
expect(result.context.exit_scroll_length).toBe('0.5')
101+
})
102+
})

src/events/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type EventProps = {
4141
is_logged_in: boolean
4242
dotcom_user: string
4343
is_staff: boolean
44+
octo_client_id?: string
4445
os: string
4546
os_version: string
4647
browser: string

src/frame/middleware/helmet.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ const DEFAULT_OPTIONS = {
2828
prefetchSrc: ["'self'"],
2929
// When doing local dev, especially in Safari, you need to add `ws:`
3030
// which NextJS uses for the hot module reloading.
31-
connectSrc: ["'self'", isDev && 'ws:'].filter(Boolean) as string[],
31+
connectSrc: ["'self'", 'https://collector.githubapp.com', isDev && 'ws:'].filter(
32+
Boolean,
33+
) as string[],
3234
fontSrc: ["'self'", 'data:'],
3335
imgSrc: [...GITHUB_DOMAINS, 'data:', 'placehold.it'],
3436
objectSrc: ["'self'"],

0 commit comments

Comments
 (0)