Skip to content

Commit bafffa6

Browse files
committed
feat(server): hide internals from stack trace
By default internals from the twilio-run dev server will no-longer be part of the stack trace. Instead we'll display them as Twilio Dev Server internals. More info in the README
1 parent fc82417 commit bafffa6

File tree

9 files changed

+289
-7
lines changed

9 files changed

+289
-7
lines changed

README.md

+41
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,47 @@ app.all(handlerToExpressRoute(handler));
209209
app.listen(3000, () => console.log('Server running on port 3000'));
210210
```
211211

212+
## Error Handling in Dev Server
213+
214+
If your local Twilio Function throws an unhandled error or returns an `Error` instance via the `callback` method, we will return an HTTP status code of `500` and return the error object as JSON.
215+
216+
By default we will clean up the stack trace for you to remove internal code of the dev server and add it as `at [Twilio Dev Server internals]` into the stack trace.
217+
218+
An example would look like this:
219+
220+
```
221+
Error: What?
222+
at format (/Users/dkundel/dev/twilio-run/examples/basic/functions/hello.js:5:9)
223+
at exports.handler (/Users/dkundel/dev/twilio-run/examples/basic/functions/hello.js:13:3)
224+
at [Twilio Dev Server internals]
225+
```
226+
227+
If you want to have the full un-modified stack trace instead, set the following environment variable, either in your Twilio Function or via `.env`:
228+
229+
```
230+
TWILIO_SERVERLESS_FULL_ERRORS=true
231+
```
232+
233+
This will result into a stack trace like this:
234+
235+
```
236+
Error: What?
237+
at format (/Users/dkundel/dev/twilio-run/examples/basic/functions/hello.js:5:9)
238+
at exports.handler (/Users/dkundel/dev/twilio-run/examples/basic/functions/hello.js:13:3)
239+
at twilioFunctionHandler (/Users/dkundel/dev/twilio-run/dist/runtime/route.js:125:13)
240+
at app.all (/Users/dkundel/dev/twilio-run/dist/runtime/server.js:122:82)
241+
at Layer.handle [as handle_request] (/Users/dkundel/dev/twilio-run/node_modules/express/lib/router/layer.js:95:5)
242+
at next (/Users/dkundel/dev/twilio-run/node_modules/express/lib/router/route.js:137:13)
243+
at next (/Users/dkundel/dev/twilio-run/node_modules/express/lib/router/route.js:131:14)
244+
at next (/Users/dkundel/dev/twilio-run/node_modules/express/lib/router/route.js:131:14)
245+
at next (/Users/dkundel/dev/twilio-run/node_modules/express/lib/router/route.js:131:14)
246+
at next (/Users/dkundel/dev/twilio-run/node_modules/express/lib/router/route.js:131:14)
247+
```
248+
249+
In general you'll want to use the cleaned-up stack trace since the internals might change throughout time.
250+
251+
252+
212253
## Contributing
213254

214255
This project welcomes contributions from the community. Please see the [`CONTRIBUTING.md`](CONTRIBUTING.md) file for more details.

__tests__/runtime/route.test.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from '../../src/runtime/route';
2121
import { EnvironmentVariablesWithAuth } from '../../src/types/generic';
2222
import { wrapErrorInHtml } from '../../src/utils/error-html';
23+
import { cleanUpStackTrace } from '../../src/utils/stack-trace/clean-up';
2324

2425
const { VoiceResponse, MessagingResponse, FaxResponse } = twiml;
2526

@@ -87,9 +88,14 @@ describe('handleError function', () => {
8788
} as ExpressUseragent.UserAgent;
8889

8990
const err = new Error('Failed to execute');
91+
const cleanedupError = cleanUpStackTrace(err);
9092
handleError(err, mockRequest, mockResponse);
9193
expect(mockResponse.status).toHaveBeenCalledWith(500);
92-
expect(mockResponse.send).toHaveBeenCalledWith(err.toString());
94+
expect(mockResponse.send).toHaveBeenCalledWith({
95+
message: 'Failed to execute',
96+
name: 'Error',
97+
stack: cleanedupError.stack,
98+
});
9399
});
94100
});
95101

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { cleanUpStackTrace } from '../../../src/utils/stack-trace/clean-up';
2+
3+
jest.mock('../../../src/utils/stack-trace/helpers', () => {
4+
return {
5+
...jest.requireActual('../../../src/utils/stack-trace/helpers'),
6+
formatStackTraceWithInternals: jest
7+
.fn()
8+
.mockReturnValue('[mock stacktrace]'),
9+
};
10+
});
11+
12+
describe('cleanUpStackTrace', () => {
13+
beforeEach(() => {
14+
delete process.env.TWILIO_SERVERLESS_FULL_ERRORS;
15+
});
16+
17+
afterAll(() => {
18+
delete process.env.TWILIO_SERVERLESS_FULL_ERRORS;
19+
});
20+
21+
test('overrides stack trace by default', () => {
22+
const err = new Error('Hello');
23+
cleanUpStackTrace(err);
24+
expect(err.stack).toBe('[mock stacktrace]');
25+
expect(err.name).toBe('Error');
26+
expect(err.message).toBe('Hello');
27+
});
28+
29+
test('leaves stack trace if env variable is set', () => {
30+
process.env.TWILIO_SERVERLESS_FULL_ERRORS = 'true';
31+
const err = new Error('Hello');
32+
cleanUpStackTrace(err);
33+
expect(err.stack).not.toBe('[mock stacktrace]');
34+
expect(err.name).toBe('Error');
35+
expect(err.message).toBe('Hello');
36+
});
37+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { stripIndent } from 'common-tags';
2+
import path from 'path';
3+
import {
4+
formatStackTraceWithInternals,
5+
MODULE_ROOT,
6+
stringifyStackTrace,
7+
} from '../../../src/utils/stack-trace/helpers';
8+
9+
function generateMockCallSite(
10+
str = `someTest (randomFakeFile.js:10:10)`,
11+
fileName = 'randomFakeFile.js'
12+
) {
13+
return {
14+
getThis: jest.fn(),
15+
getTypeName: jest.fn(),
16+
getFunction: jest.fn(),
17+
getFunctionName: jest.fn(),
18+
getMethodName: jest.fn(),
19+
getFileName: jest.fn().mockReturnValue(fileName),
20+
getLineNumber: jest.fn(),
21+
getColumnNumber: jest.fn(),
22+
getEvalOrigin: jest.fn(),
23+
isToplevel: jest.fn(),
24+
isEval: jest.fn(),
25+
isNative: jest.fn(),
26+
isConstructor: jest.fn(),
27+
isAsync: jest.fn(),
28+
isPromiseAll: jest.fn(),
29+
getPromiseIndex: jest.fn(),
30+
toString: () => {
31+
return str;
32+
},
33+
};
34+
}
35+
36+
describe('MODULE_ROOT', () => {
37+
test('module root points at root of source code', () => {
38+
expect(MODULE_ROOT.endsWith('src')).toBe(true);
39+
});
40+
});
41+
42+
describe('stringifyStackTrace', () => {
43+
test('formats error correctly', () => {
44+
const err = new Error('Test error message');
45+
err.name = 'FakeTestError';
46+
const callsites = [generateMockCallSite()];
47+
const stackString = stringifyStackTrace(err, callsites);
48+
expect(stackString).toEqual(stripIndent`
49+
FakeTestError: Test error message
50+
at someTest (randomFakeFile.js:10:10)
51+
`);
52+
});
53+
54+
test('handles multiple callsites', () => {
55+
const err = new Error('Test error message');
56+
err.name = 'FakeTestError';
57+
const callsites = [
58+
generateMockCallSite(),
59+
generateMockCallSite('anotherTest (randomFakeFile.js:20:0)'),
60+
];
61+
const stackString = stringifyStackTrace(err, callsites);
62+
expect(stackString).toEqual(stripIndent`
63+
FakeTestError: Test error message
64+
at someTest (randomFakeFile.js:10:10)
65+
at anotherTest (randomFakeFile.js:20:0)
66+
`);
67+
});
68+
});
69+
70+
describe('formatStackTraceWithInternals', () => {
71+
test('does not add "internals" footer if no internals', () => {
72+
const err = new Error('Test error message');
73+
err.name = 'FakeTestError';
74+
const callsites = [generateMockCallSite()];
75+
const stackString = formatStackTraceWithInternals(err, callsites);
76+
expect(stackString).toEqual(stripIndent`
77+
FakeTestError: Test error message
78+
at someTest (randomFakeFile.js:10:10)
79+
`);
80+
});
81+
82+
test('adds "internals" footer if internals are present', () => {
83+
const err = new Error('Test error message');
84+
err.name = 'FakeTestError';
85+
const callsites = [
86+
generateMockCallSite(),
87+
generateMockCallSite(
88+
'randomInternalThatShouldBeRemoved (randomFakeFile.js:20:0)',
89+
path.join(MODULE_ROOT, 'randomFakeFile.js')
90+
),
91+
];
92+
const stackString = formatStackTraceWithInternals(err, callsites);
93+
expect(stackString).toEqual(stripIndent`
94+
FakeTestError: Test error message
95+
at someTest (randomFakeFile.js:10:10)
96+
at [Twilio Dev Server internals]
97+
`);
98+
});
99+
});

examples/basic/functions/error.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/// <reference path="../../../node_modules/@twilio-labs/serverless-runtime-types/index.d.ts"/>
2+
3+
exports.handler = function(context, event, callback) {
4+
const err = new Error('Something went wrong');
5+
err.name = 'WebhookError';
6+
callback(err);
7+
};

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@
112112
"standard-version": "^6.0.1",
113113
"supertest": "^3.1.0",
114114
"ts-jest": "^24.0.2",
115-
"typescript": "^3.5.2"
115+
"typescript": "^3.7.4"
116116
},
117117
"files": [
118118
"bin/",

src/runtime/route.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { checkForValidAccountSid } from '../checks/check-account-sid';
1414
import { StartCliConfig } from '../config/start';
1515
import { wrapErrorInHtml } from '../utils/error-html';
1616
import { getDebugFunction } from '../utils/logger';
17+
import { cleanUpStackTrace } from '../utils/stack-trace/clean-up';
1718
import { Response } from './internal/response';
1819
import * as Runtime from './internal/runtime';
1920

@@ -67,22 +68,32 @@ export function constructGlobalScope(config: StartCliConfig): void {
6768
}
6869
}
6970

71+
function isError(obj: any): obj is Error {
72+
return obj instanceof Error;
73+
}
74+
7075
export function handleError(
7176
err: Error | string | object,
7277
req: ExpressRequest,
7378
res: ExpressResponse,
7479
functionFilePath?: string
7580
) {
7681
res.status(500);
77-
if (!(err instanceof Error)) {
78-
res.send(err);
79-
} else {
82+
if (isError(err)) {
83+
const cleanedupError = cleanUpStackTrace(err);
84+
8085
if (req.useragent && (req.useragent.isDesktop || req.useragent.isMobile)) {
8186
res.type('text/html');
82-
res.send(wrapErrorInHtml(err, functionFilePath));
87+
res.send(wrapErrorInHtml(cleanedupError, functionFilePath));
8388
} else {
84-
res.send(err.toString());
89+
res.send({
90+
message: cleanedupError.message,
91+
name: cleanedupError.name,
92+
stack: cleanedupError.stack,
93+
});
8594
}
95+
} else {
96+
res.send(err);
8697
}
8798
}
8899

src/utils/stack-trace/clean-up.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { formatStackTraceWithInternals } from './helpers';
2+
3+
/**
4+
* Uses the V8 Stack Trace API to hide any twilio-run internals from stack traces
5+
* https://v8.dev/docs/stack-trace-api
6+
*
7+
* @param err Instance that inherits from Error
8+
*/
9+
export function cleanUpStackTrace<T extends Error>(err: T): T {
10+
if (typeof process.env.TWILIO_SERVERLESS_FULL_ERRORS !== 'undefined') {
11+
return err;
12+
}
13+
14+
const backupPrepareStackTrace = Error.prepareStackTrace;
15+
Error.prepareStackTrace = formatStackTraceWithInternals;
16+
err.stack = err.stack;
17+
Error.prepareStackTrace = backupPrepareStackTrace;
18+
return err;
19+
}

src/utils/stack-trace/helpers.ts

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import path from 'path';
2+
3+
export const MODULE_ROOT = path.resolve(__dirname, '../../');
4+
5+
/**
6+
* Turns a set of stack frames into a stack trace string equivalent to the one
7+
* generated by V8
8+
*
9+
* https://v8.dev/docs/stack-trace-api
10+
*
11+
* @param err The error instance for this stack trace
12+
* @param stack Callsite instances for each frame from V8
13+
*/
14+
export function stringifyStackTrace(err: Error, stack: NodeJS.CallSite[]) {
15+
const callSiteStrings = stack
16+
.map(callSite => {
17+
return ` at ${callSite}`;
18+
})
19+
.join('\n');
20+
const stackTrace = `${err.name}: ${err.message}\n${callSiteStrings}`;
21+
return stackTrace;
22+
}
23+
24+
/**
25+
* Returns all stack frames until one is reached that comes from inside twilio-run
26+
*
27+
* https://v8.dev/docs/stack-trace-api
28+
*
29+
* @param stack Array of callsite instances from the V8 Stack Trace API
30+
*/
31+
export function filterCallSites(stack: NodeJS.CallSite[]): NodeJS.CallSite[] {
32+
let indexOfFirstInternalCallSite = stack.findIndex(callSite =>
33+
callSite.getFileName()?.includes(MODULE_ROOT)
34+
);
35+
indexOfFirstInternalCallSite =
36+
indexOfFirstInternalCallSite === -1
37+
? stack.length
38+
: indexOfFirstInternalCallSite;
39+
return stack.slice(0, indexOfFirstInternalCallSite);
40+
}
41+
42+
/**
43+
* Removes any stack traces that are internal to twilio-run and replaces it
44+
* with one [Twilio Dev Server internals] statement.
45+
*
46+
* To be used with Error.prepareStackTrace from the V8 Stack Trace API
47+
* https://v8.dev/docs/stack-trace-api
48+
*
49+
* @param err The error instance for this stack trace
50+
* @param stack Callsite instances for each from from V8 Stack Trace API
51+
*/
52+
export function formatStackTraceWithInternals(
53+
err: Error,
54+
stack: NodeJS.CallSite[]
55+
): string {
56+
const filteredStack = filterCallSites(stack);
57+
const stackTraceWithoutInternals = stringifyStackTrace(err, filteredStack);
58+
if (filteredStack.length === stack.length) {
59+
return stackTraceWithoutInternals;
60+
}
61+
return `${stackTraceWithoutInternals}\n at [Twilio Dev Server internals]`;
62+
}

0 commit comments

Comments
 (0)