Skip to content

Commit 84bccc6

Browse files
authored
fix: request id and error message in error metric (#1076)
#### for context, here is the agentic-chat branch pr which i am porting to `language-servers`: aws/aws-toolkit-vscode#7045 ## Problem - do not have requestID and errorMessage in amazonq_messageResponseError ## Solution - emit amazonq_messageResponseError event with requestID and errorMessage - ported `getTelemetryReasonDesc` and all of its corresponding helpers from: https://github.com/aws/aws-toolkit-vscode/blob/master/packages/core/src/shared/errors.ts#L455
1 parent 68df8f9 commit 84bccc6

File tree

5 files changed

+185
-6
lines changed

5 files changed

+185
-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
@@ -275,7 +275,7 @@ export class AgenticChatController implements ChatHandlers {
275275
}
276276

277277
const metric = new Metric<CombinedConversationEvent>({
278-
cwsprChatConversationType: 'Chat',
278+
cwsprChatConversationType: 'AgenticChat',
279279
})
280280

281281
const triggerContext = await this.#getTriggerContext(params, metric)
@@ -981,7 +981,7 @@ export class AgenticChatController implements ChatHandlers {
981981
): ChatResult | ResponseError<ChatResult> {
982982
if (isAwsError(err) || (isObject(err) && 'statusCode' in err && typeof err.statusCode === 'number')) {
983983
metric.setDimension('cwsprChatRepsonseCode', err.statusCode ?? 400)
984-
this.#telemetryController.emitMessageResponseError(tabId, metric.metric)
984+
this.#telemetryController.emitMessageResponseError(tabId, metric.metric, err.requestId, err.message)
985985
}
986986

987987
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: 163 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,168 @@ export function isAwsError(error: unknown): error is AWSError {
1414
return error instanceof Error && hasCode(error) && hasTime(error)
1515
}
1616

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

0 commit comments

Comments
 (0)