Skip to content

Commit 8b21952

Browse files
refactor(telemetry): add context to errors
The withTelemetryContext() decorator adds context to telemetry but not thrown errors. Solution: Adding this decorator to a method will add context to any thrown exceptions. This is helpful in telemetry as it will provide information about the caller. Signed-off-by: nkomonen-amazon <[email protected]>
1 parent 17e951c commit 8b21952

File tree

3 files changed

+127
-35
lines changed

3 files changed

+127
-35
lines changed

docs/telemetry.md

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,10 @@ Finally, if `setupStep2()` was the thing that failed we would see a metric like:
142142

143143
## Adding a "Stack Trace" to your metric
144144

145-
### Problem
145+
When errors are thrown we do not attach the stack trace in telemetry. We only know about the error itself, but
146+
not the path it took to get there. We sometimes need this stack trace to debug, and only have telemetry to get insight on what happened since we do not have access to logs.
147+
148+
### Scenario
146149

147150
Common example: _"I have a function, `thisFailsSometimes()` that is called in multiple places. The function sometimes fails, I know from telemetry, but I do not know if it is failing when it is a specific caller. If I knew the call stack/trace that it took to call my function that would help me debug."_
148151

@@ -168,34 +171,67 @@ function thisFailsSometimes(num: number) {
168171
### Solution
169172

170173
Add a value to `function` in the options of a `run()`. This will result in a stack of functions identifiers that were previously called
171-
before `thisFailsSometimes()` was run. You can then retrieve the stack in the `run()` of your final metric using `getFunctionStack()`.
174+
before `failsDependingOnInput()` was run. You can then retrieve the stack in the `run()` of your final metric using `getFunctionStack()`.
172175

173176
```typescript
174-
function outerA() {
175-
telemetry.my_Metric.run(() => thisFailsSometimes(1), { functionId: { name: 'outerA' }})
177+
function runsSuccessfully() {
178+
telemetry.my_Metric.run(() => failsDependingOnInput(1), { functionId: { name: 'runsSuccessfully' }})
176179
}
177180

178-
function outerB() {
179-
telemetry.my_Metric.run(() => thisFailsSometimes(0), { functionId: { source: 'outerB' }})
181+
function thisThrows() {
182+
telemetry.my_Metric.run(() => failsDependingOnInput(0), { functionId: { source: 'thisThrows' }})
180183
}
181184

182-
function thisFailsSometimes(num: number) {
185+
function failsDependingOnInput(num: number) {
183186
return telemetry.my_Metric.run(() => {
184187
telemetry.record({ theCallStack: asStringifiedStack(telemetry.getFunctionStack())})
185188
if (number === 0) {
186189
throw Error('Cannot be 0')
187190
}
188191
...
189-
}, { functionId: { name: 'thisFailsSometimes' }})
192+
}, { functionId: { name: 'failsDependingOnInput' }})
190193
}
191194

192-
// Results in a metric: { theCallStack: 'outerB:thisFailsSometimes', result: 'Failed' }
193-
// { theCallStack: 'outerB:thisFailsSometimes' } implies 'outerB' was run first, then 'thisFailsSometimes'. See docstrings for more info.
194-
outerB()
195+
// Results in a metric: { theCallStack: 'thisThrows:failsDependingOnInput', result: 'Failed' }
196+
// { theCallStack: 'thisThrows:failsDependingOnInput' } implies 'thisThrows' was run first, then 'failsDependingOnInput'. See docstrings for more info.
197+
thisThrows()
198+
```
199+
200+
Additionally the `@withTelemetryContext()` decorator can be added to methods to do the same as above, but with a cleaner syntax.
201+
202+
```typescript
203+
class MyClass {
204+
@withTelemetryContext({ name: 'runsSuccessfully', class: 'MyClass' })
205+
public runsSuccessfully() {
206+
failsDependingOnInput(1)
207+
}
208+
209+
@withTelemetryContext({ name: 'thisThrows', class: 'MyClass' })
210+
public thisThrows() {
211+
failsDependingOnInput(0)
212+
}
213+
214+
private failsDependingOnInput(num: number) {
215+
return telemetry.my_Metric.run(() => {
216+
telemetry.record({ theCallStack: asStringifiedStack(telemetry.getFunctionStack())})
217+
if (number === 0) {
218+
throw Error('Cannot be 0')
219+
}
220+
...
221+
}, { functionId: { name: 'failsDependingOnInput' }})
222+
}
223+
224+
}
225+
226+
227+
// Results in a metric: { theCallStack: 'MyClass#thisThrows,failsDependingOnInput', result: 'Failed' }
228+
new MyClass().thisThrows()
195229
```
196230

197231
### Important Notes
198232

233+
- Using `@withTelemetryContext` will wrap errors with the functionId properties to add more context
234+
199235
- If a nested function does not use a `run()` then it will not be part of the call stack.
200236

201237
```typescript
@@ -216,25 +252,6 @@ outerB()
216252
c() // result: 'a:c', note that 'b' is not included
217253
```
218254

219-
- If you are using `run()` with a class method, you can also add the class to the entry for more context
220-
221-
```typescript
222-
class A {
223-
a() {
224-
return telemetry.my_Metric.run(() => this.b(), { functionId: { name: 'a', class: 'A' } })
225-
}
226-
227-
b() {
228-
return telemetry.my_Metric.run(() => asStringifiedStack(telemetry.getFunctionStack()), {
229-
functionId: { name: 'b', class: 'A' },
230-
})
231-
}
232-
}
233-
234-
const inst = new A()
235-
inst.a() // 'A#a,b'
236-
```
237-
238255
- If you do not want your `run()` to emit telemetry, set `emit: false` in the options
239256

240257
```typescript

packages/core/src/shared/telemetry/util.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ import { isValidationExemptMetric } from './exemptMetrics'
2626
import { isAmazonQ, isCloud9, isSageMaker } from '../../shared/extensionUtilities'
2727
import { randomUUID } from '../crypto'
2828
import { ClassToInterfaceType } from '../utilities/tsUtils'
29-
import { FunctionEntry, type TelemetryTracer } from './spans'
29+
import { FunctionEntry } from './spans'
3030
import { telemetry } from './telemetry'
3131
import { v5 as uuidV5 } from 'uuid'
32+
import { ToolkitError } from '../errors'
3233

3334
const legacySettingsTelemetryValueDisable = 'Disable'
3435
const legacySettingsTelemetryValueEnable = 'Enable'
@@ -341,7 +342,7 @@ export function getOperatingSystem(): OperatingSystem {
341342

342343
/**
343344
* Decorator that simply wraps the method with a non-emitting telemetry `run()`, automatically
344-
* `record()`ing the provided function id for later use by {@link TelemetryTracer.getFunctionStack()}
345+
* `record()`ing the provided function id for later use by TelemetryTracer.getFunctionStack()
345346
*
346347
* This saves us from needing to wrap the entire function:
347348
*
@@ -376,8 +377,18 @@ export function withTelemetryContext(functionId: FunctionEntry) {
376377
function decoratedMethod(this: This, ...args: Args): Return {
377378
return telemetry.function_call.run(
378379
() => {
379-
// DEVELOPERS: Set a breakpoint here and step in to it to debug the original function
380-
return originalMethod.call(this, ...args)
380+
try {
381+
// DEVELOPERS: Set a breakpoint here and step in to it to debug the original function
382+
const result = originalMethod.call(this, ...args)
383+
if (result instanceof Promise) {
384+
return result.catch((e) => {
385+
throw addContextToError(e, functionId)
386+
}) as Return
387+
}
388+
return result
389+
} catch (e) {
390+
throw addContextToError(e, functionId)
391+
}
381392
},
382393
{
383394
emit: false,
@@ -388,4 +399,10 @@ export function withTelemetryContext(functionId: FunctionEntry) {
388399
return decoratedMethod
389400
}
390401
return decorator
402+
403+
function addContextToError(e: unknown, functionId: FunctionEntry) {
404+
return ToolkitError.chain(e, `ctx: ${functionId.class ? functionId.class + '#' : ''}${functionId.name}`, {
405+
code: functionId.class,
406+
})
407+
}
391408
}

packages/core/src/test/shared/telemetry/spans.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import assert from 'assert'
7-
import { ToolkitError } from '../../../shared/errors'
7+
import { getErrorId, ToolkitError } from '../../../shared/errors'
88
import { asStringifiedStack, FunctionEntry, TelemetrySpan, TelemetryTracer } from '../../../shared/telemetry/spans'
99
import { MetricName, MetricShapes, telemetry } from '../../../shared/telemetry/telemetry'
1010
import { assertTelemetry, getMetrics, installFakeClock } from '../../testUtil'
@@ -588,6 +588,64 @@ describe('TelemetryTracer', function () {
588588
inst.doesNotEmit()
589589
assertTelemetry('function_call', [])
590590
})
591+
592+
class TestThrows {
593+
@withTelemetryContext({ name: 'throwsError', class: 'TestThrows' })
594+
throwsError() {
595+
throw arbitraryError
596+
}
597+
598+
@withTelemetryContext({ name: 'throwsError' })
599+
throwsErrorButNoClass() {
600+
throw arbitraryError
601+
}
602+
603+
@withTelemetryContext({ name: 'throwsError' })
604+
async throwsAsyncError() {
605+
throw arbitraryError
606+
}
607+
}
608+
const arbitraryError = new Error('arbitrary error')
609+
610+
it(`withTelemetryContext wraps errors with function id context`, async function () {
611+
const inst = new TestThrows()
612+
assert.throws(
613+
() => inst.throwsError(),
614+
(e) => {
615+
if (!(e instanceof ToolkitError)) {
616+
return false
617+
}
618+
const id = getErrorId(e)
619+
const message = e.message
620+
const cause = e.cause
621+
return id === 'TestThrows' && message === 'ctx: throwsError' && cause === arbitraryError
622+
}
623+
)
624+
assert.throws(
625+
() => inst.throwsErrorButNoClass(),
626+
(e) => {
627+
if (!(e instanceof ToolkitError)) {
628+
return false
629+
}
630+
const id = getErrorId(e)
631+
const message = e.message
632+
const cause = e.cause
633+
return id === 'Error' && message === 'ctx: throwsError' && cause === arbitraryError
634+
}
635+
)
636+
await assert.rejects(
637+
() => inst.throwsAsyncError(),
638+
(e) => {
639+
if (!(e instanceof ToolkitError)) {
640+
return false
641+
}
642+
const id = getErrorId(e)
643+
const message = e.message
644+
const cause = e.cause
645+
return id === 'Error' && message === 'ctx: throwsError' && cause === arbitraryError
646+
}
647+
)
648+
})
591649
})
592650
})
593651

0 commit comments

Comments
 (0)