Skip to content

Commit cb5c2cd

Browse files
committed
fix: request id and error message in error metric
1 parent a0ca8e0 commit cb5c2cd

File tree

5 files changed

+192
-6
lines changed

5 files changed

+192
-6
lines changed

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export class AgenticChatController implements ChatHandlers {
251251
}
252252

253253
const metric = new Metric<CombinedConversationEvent>({
254-
cwsprChatConversationType: 'Chat',
254+
cwsprChatConversationType: 'AgenticChat',
255255
})
256256

257257
const triggerContext = await this.#getTriggerContext(params, metric)
@@ -801,7 +801,7 @@ export class AgenticChatController implements ChatHandlers {
801801
): ChatResult | ResponseError<ChatResult> {
802802
if (isAwsError(err) || (isObject(err) && 'statusCode' in err && typeof err.statusCode === 'number')) {
803803
metric.setDimension('cwsprChatRepsonseCode', err.statusCode ?? 400)
804-
this.#telemetryController.emitMessageResponseError(tabId, metric.metric)
804+
this.#telemetryController.emitMessageResponseError(tabId, metric.metric, err.requestId, err.message)
805805
}
806806

807807
if (err instanceof AmazonQServicePendingSigninError) {

server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { TriggerContext } from '../contexts/triggerContext'
2020
import { CredentialsProvider, Logging } from '@aws/language-server-runtimes/server-interface'
2121
import { AcceptedSuggestionEntry, CodeDiffTracker } from '../../inline-completion/codeDiffTracker'
2222
import { TelemetryService } from '../../../shared/telemetry/telemetryService'
23-
import { getEndPositionForAcceptedSuggestion } from '../../../shared/utils'
23+
import { getEndPositionForAcceptedSuggestion, getTelemetryReasonDesc } from '../../../shared/utils'
2424
import { CodewhispererLanguage } from '../../../shared/languageDetection'
2525

2626
export const CONVERSATION_ID_METRIC_KEY = 'cwsprChatConversationId'
@@ -228,7 +228,12 @@ export class ChatTelemetryController {
228228
})
229229
}
230230

231-
public emitMessageResponseError(tabId: string, metric: Partial<CombinedConversationEvent>) {
231+
public emitMessageResponseError(
232+
tabId: string,
233+
metric: Partial<CombinedConversationEvent>,
234+
requestId?: string,
235+
errorReason?: string
236+
) {
232237
this.emitConversationMetric(
233238
{
234239
name: ChatTelemetryEventName.MessageResponseError,
@@ -242,6 +247,8 @@ export class ChatTelemetryController {
242247
cwsprChatRepsonseCode: metric.cwsprChatRepsonseCode,
243248
cwsprChatRequestLength: metric.cwsprChatRequestLength,
244249
cwsprChatConversationType: metric.cwsprChatConversationType,
250+
requestId: requestId,
251+
reasonDesc: getTelemetryReasonDesc(errorReason),
245252
},
246253
},
247254
tabId

server/aws-lsp-codewhisperer/src/shared/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,13 @@ export const AWS_Q_ENDPOINT_URL_ENV_VAR = 'AWS_Q_ENDPOINT_URL'
1616

1717
export const Q_CONFIGURATION_SECTION = 'aws.q'
1818
export const CODE_WHISPERER_CONFIGURATION_SECTION = 'aws.codeWhisperer'
19+
20+
/**
21+
* Names of directories relevant to the crash reporting functionality.
22+
*
23+
* Moved here to resolve circular dependency issues.
24+
*/
25+
export const crashMonitoringDirName = 'crashMonitoring'
26+
27+
/** Matches Windows drive letter ("C:"). */
28+
export const driveLetterRegex = /^[a-zA-Z]\:/

server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export enum ChatInteractionType {
242242
ClickBodyLink = 'clickBodyLink',
243243
}
244244

245-
export type ChatConversationType = 'Chat' | 'Assign' | 'Transform'
245+
export type ChatConversationType = 'Chat' | 'Assign' | 'Transform' | 'AgenticChat'
246246

247247
export type InteractWithMessageEvent = {
248248
credentialStartUrl?: string

server/aws-lsp-codewhisperer/src/shared/utils.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { AWSError } from 'aws-sdk'
33
import { distance } from 'fastest-levenshtein'
44
import { Suggestion } from './codeWhispererService'
55
import { CodewhispererCompletionType } from './telemetry/types'
6-
import { BUILDER_ID_START_URL, MISSING_BEARER_TOKEN_ERROR } from './constants'
6+
import { BUILDER_ID_START_URL, crashMonitoringDirName, driveLetterRegex, MISSING_BEARER_TOKEN_ERROR } from './constants'
77
export type SsoConnectionType = 'builderId' | 'identityCenter' | 'none'
88

99
export function isAwsError(error: unknown): error is AWSError {
@@ -14,6 +14,175 @@ export function isAwsError(error: unknown): error is AWSError {
1414
return error instanceof Error && hasCode(error) && hasTime(error)
1515
}
1616

17+
let _username = 'unknown-user'
18+
let _isAutomation = false
19+
20+
/** Performs one-time initialization, to avoid circular dependencies. */
21+
export function init(username: string, isAutomation: boolean) {
22+
_username = username
23+
_isAutomation = isAutomation
24+
}
25+
26+
/**
27+
* Returns the identifier the given error.
28+
* Depending on the implementation, the identifier may exist on a
29+
* different property.
30+
*/
31+
export function getErrorId(error: Error): string {
32+
// prioritize code over the name
33+
return hasCode(error) ? error.code : error.name
34+
}
35+
36+
/**
37+
* Derives an error message from the given error object.
38+
* Depending on the Error, the property used to derive the message can vary.
39+
*
40+
* @param withCause Append the message(s) from the cause chain, recursively.
41+
* The message(s) are delimited by ' | '. Eg: msg1 | causeMsg1 | causeMsg2
42+
*/
43+
export function getErrorMsg(err: Error | undefined, withCause: boolean = false): string | undefined {
44+
if (err === undefined) {
45+
return undefined
46+
}
47+
48+
// Non-standard SDK fields added by the OIDC service, to conform to the OAuth spec
49+
// (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1) :
50+
// - error: code per the OAuth spec
51+
// - error_description: improved error message provided by OIDC service. Prefer this to
52+
// `message` if present.
53+
// https://github.com/aws/aws-toolkit-jetbrains/commit/cc9ed87fa9391dd39ac05cbf99b4437112fa3d10
54+
// - error_uri: not provided by OIDC currently?
55+
//
56+
// Example:
57+
//
58+
// [error] API response (oidc.us-east-1.amazonaws.com /token): {
59+
// name: 'InvalidGrantException',
60+
// '$fault': 'client',
61+
// '$metadata': {
62+
// httpStatusCode: 400,
63+
// requestId: '7f5af448-5af7-45f2-8e47-5808deaea4ab',
64+
// extendedRequestId: undefined,
65+
// cfId: undefined
66+
// },
67+
// error: 'invalid_grant',
68+
// error_description: 'Invalid refresh token provided',
69+
// message: 'UnknownError'
70+
// }
71+
const anyDesc = (err as any).error_description
72+
const errDesc = typeof anyDesc === 'string' ? anyDesc.trim() : ''
73+
let msg = errDesc !== '' ? errDesc : err.message?.trim()
74+
75+
if (typeof msg !== 'string') {
76+
return undefined
77+
}
78+
79+
// append the cause's message
80+
if (withCause) {
81+
const errorId = getErrorId(err)
82+
// - prepend id to message
83+
// - If a generic error does not have the `name` field explicitly set, it returns a generic 'Error' name. So skip since it is useless.
84+
if (errorId && errorId !== 'Error') {
85+
msg = `${errorId}: ${msg}`
86+
}
87+
88+
const cause = (err as any).cause
89+
return `${msg}${cause ? ' | ' + getErrorMsg(cause, withCause) : ''}`
90+
}
91+
92+
return msg
93+
}
94+
95+
/**
96+
* Removes potential PII from a string, for logging/telemetry.
97+
*
98+
* Examples:
99+
* - "Failed to save c:/fooß/bar/baz.txt" => "Failed to save c:/xß/x/x.txt"
100+
* - "EPERM for dir c:/Users/user1/.aws/sso/cache/abc123.json" => "EPERM for dir c:/Users/x/.aws/sso/cache/x.json"
101+
*/
102+
export function scrubNames(s: string, username?: string) {
103+
let r = ''
104+
const fileExtRe = /\.[^.\/]+$/
105+
const slashdot = /^[~.]*[\/\\]*/
106+
107+
/** Allowlisted filepath segments. */
108+
const keep = new Set<string>([
109+
'~',
110+
'.',
111+
'..',
112+
'.aws',
113+
'aws',
114+
'sso',
115+
'cache',
116+
'credentials',
117+
'config',
118+
'Users',
119+
'users',
120+
'home',
121+
'tmp',
122+
'aws-toolkit-vscode',
123+
'globalStorage', // from vscode globalStorageUri
124+
crashMonitoringDirName,
125+
])
126+
127+
if (username && username.length > 2) {
128+
s = s.replaceAll(username, 'x')
129+
}
130+
131+
// Replace contiguous whitespace with 1 space.
132+
s = s.replace(/\s+/g, ' ')
133+
134+
// 1. split on whitespace.
135+
// 2. scrub words that match username or look like filepaths.
136+
const words = s.split(/\s+/)
137+
for (const word of words) {
138+
const pathSegments = word.split(/[\/\\]/)
139+
if (pathSegments.length < 2) {
140+
// Not a filepath.
141+
r += ' ' + word
142+
continue
143+
}
144+
145+
// Replace all (non-allowlisted) ASCII filepath segments with "x".
146+
// "/foo/bar/aws/sso/" => "/x/x/aws/sso/"
147+
let scrubbed = ''
148+
// Get the frontmatter ("/", "../", "~/", or "./").
149+
const start = word.trimStart().match(slashdot)?.[0] ?? ''
150+
pathSegments[0] = pathSegments[0].trimStart().replace(slashdot, '')
151+
for (const seg of pathSegments) {
152+
if (driveLetterRegex.test(seg)) {
153+
scrubbed += seg
154+
} else if (keep.has(seg)) {
155+
scrubbed += '/' + seg
156+
} else {
157+
// Save the first non-ASCII (unicode) char, if any.
158+
const nonAscii = seg.match(/[^\p{ASCII}]/u)?.[0] ?? ''
159+
// Replace all chars (except [^…]) with "x" .
160+
const ascii = seg.replace(/[^$[\](){}:;'" ]+/g, 'x')
161+
scrubbed += `/${ascii}${nonAscii}`
162+
}
163+
}
164+
165+
// includes leading '.', eg: '.json'
166+
const fileExt = pathSegments[pathSegments.length - 1].match(fileExtRe) ?? ''
167+
r += ` ${start.replace(/\\/g, '/')}${scrubbed.replace(/^[\/\\]+/, '')}${fileExt}`
168+
}
169+
170+
return r.trim()
171+
}
172+
173+
/**
174+
* Gets the (partial) error message detail for the `reasonDesc` field.
175+
*
176+
* @param err Error object, or message text
177+
*/
178+
export function getTelemetryReasonDesc(err: unknown | undefined): string | undefined {
179+
const m = typeof err === 'string' ? err : (getErrorMsg(err as Error, true) ?? '')
180+
const msg = scrubNames(m, _username)
181+
182+
// Truncate message as these strings can be very long.
183+
return msg && msg.length > 0 ? msg.substring(0, 350) : undefined
184+
}
185+
17186
function hasCode<T>(error: T): error is T & { code: string } {
18187
return typeof (error as { code?: unknown }).code === 'string'
19188
}

0 commit comments

Comments
 (0)