@@ -12,27 +12,40 @@ export async function handleExternalSearchAnalytics(
12
12
const host = req . headers [ 'x-host' ] || req . headers . host
13
13
const normalizedHost = stripPort ( host as string )
14
14
15
- // Skip analytics entirely for production and internal staging environments
16
- if (
17
- normalizedHost === 'docs.github.com' ||
18
- normalizedHost . endsWith ( '.github.net' ) ||
19
- normalizedHost . endsWith ( '.githubapp.com' )
20
- ) {
21
- return null
22
- }
15
+ // Check if this is likely an external API call rather than a browser request
16
+ const isLikelyExternalAPI = isExternalAPIRequest ( req )
23
17
24
- // For localhost, send analytics but auto-set client_name if not provided
18
+ // Get client_name from query or body
25
19
let client_name = req . query . client_name || req . body ?. client_name
26
- if ( normalizedHost === 'localhost' && ! client_name ) {
27
- client_name = 'localhost'
20
+
21
+ // Rule 1: Skip analytics for browser requests from our own frontend
22
+ if ( ! isLikelyExternalAPI && client_name === 'docs.github.com-client' ) {
23
+ return null
28
24
}
29
25
30
- // For all other external requests, require explicit client_name
31
- if ( ! client_name ) {
32
- return {
33
- status : 400 ,
34
- error : "Missing required parameter 'client_name' for external requests" ,
26
+ // Rule 2: Send analytics for any request with a client_name that's not 'docs.github.com-client'
27
+ // (This includes partner APIs and other external clients)
28
+ if ( client_name && client_name !== 'docs.github.com-client' ) {
29
+ // Analytics will be sent at the end of this function
30
+ }
31
+ // Rule 3: For requests without client_name, require it for external API requests
32
+ else if ( ! client_name ) {
33
+ if ( isLikelyExternalAPI ) {
34
+ return {
35
+ status : 400 ,
36
+ error : "Missing required parameter 'client_name' for external requests" ,
37
+ }
35
38
}
39
+ // For browser requests without client_name to internal environments, skip analytics
40
+ else if ( normalizedHost . endsWith ( '.github.net' ) || normalizedHost . endsWith ( '.githubapp.com' ) ) {
41
+ return null
42
+ }
43
+ // For localhost development without client_name, we'll still send analytics below
44
+ }
45
+
46
+ // For localhost, ensure we have a client_name for analytics
47
+ if ( normalizedHost === 'localhost' && ! client_name ) {
48
+ client_name = 'localhost'
36
49
}
37
50
38
51
// Send search event with client identifier
@@ -71,19 +84,16 @@ export async function handleExternalSearchAnalytics(
71
84
72
85
/**
73
86
* Determines if a host should bypass client_name requirement for analytics
74
- * Returns true if the host is docs.github.com or ends with github.net or githubapp.com
75
- * (for production and internal staging environments)
87
+ * Returns true if the host ends with github.net or githubapp.com
88
+ * (for internal staging environments)
89
+ * Note: docs.github.com is removed since normalizedHost will always be docs.github.com in production
76
90
* Note: localhost is NOT included here as it should send analytics with auto-set client_name
77
91
*/
78
92
export function shouldBypassClientNameRequirement ( host : string | undefined ) : boolean {
79
93
if ( ! host ) return false
80
94
81
95
const normalizedHost = stripPort ( host )
82
- return (
83
- normalizedHost === 'docs.github.com' ||
84
- normalizedHost . endsWith ( '.github.net' ) ||
85
- normalizedHost . endsWith ( '.githubapp.com' )
86
- )
96
+ return normalizedHost . endsWith ( '.github.net' ) || normalizedHost . endsWith ( '.githubapp.com' )
87
97
}
88
98
89
99
/**
@@ -93,3 +103,42 @@ function stripPort(host: string): string {
93
103
const [ hostname ] = host . split ( ':' )
94
104
return hostname
95
105
}
106
+
107
+ interface ExternalAPIRequestLike {
108
+ headers : Record < string , string | undefined >
109
+ }
110
+
111
+ /**
112
+ * Determines if a request is likely from an external API client rather than a browser
113
+ * Uses multiple heuristics to detect programmatic vs browser requests
114
+ */
115
+ const userAgentRegex = / ^ ( c u r l | w g e t | p y t h o n - r e q u e s t s | a x i o s | n o d e - f e t c h | G o - h t t p - c l i e n t | o k h t t p ) / i
116
+ function isExternalAPIRequest ( req : ExternalAPIRequestLike ) : boolean {
117
+ const headers = req . headers
118
+
119
+ // Browser security headers that APIs typically don't send
120
+ const hasSecFetchHeaders = headers [ 'sec-fetch-site' ] || headers [ 'sec-fetch-mode' ]
121
+ const hasClientHints = headers [ 'sec-ch-ua' ] || headers [ 'sec-ch-ua-mobile' ]
122
+
123
+ // Browsers typically request HTML, APIs typically request JSON
124
+ const acceptHeader = headers . accept || ''
125
+ const prefersJson =
126
+ acceptHeader . includes ( 'application/json' ) && ! acceptHeader . includes ( 'text/html' )
127
+
128
+ // Common API user agents (not exhaustive, but catches common cases)
129
+ const userAgent = headers [ 'user-agent' ] || ''
130
+ const hasAPIUserAgent = userAgentRegex . test ( userAgent )
131
+
132
+ // If it has browser-specific headers, it's likely a browser
133
+ if ( hasSecFetchHeaders || hasClientHints ) {
134
+ return false
135
+ }
136
+
137
+ // If it prefers JSON or has a common API user agent, it's likely an API
138
+ if ( prefersJson || hasAPIUserAgent ) {
139
+ return true
140
+ }
141
+
142
+ // Default to treating it as a browser request to be conservative
143
+ return false
144
+ }
0 commit comments