-
-
Save zanona/0f3d42093eaa8ac5c33286cc7eca1166 to your computer and use it in GitHub Desktop.
/** | |
* Temporary wrapper for firebase functions until @sentry/serverless support is implemented | |
* It currently supports wrapping https, pubsub and firestore handlers. | |
* usage: https.onRequest(wrap((req, res) => {...})) | |
*/ | |
import type {Event} from '@sentry/types'; | |
import type {https} from 'firebase-functions'; | |
import type {onRequest, onCall} from 'firebase-functions/lib/providers/https'; | |
import type {ScheduleBuilder} from 'firebase-functions/lib/providers/pubsub'; | |
import type {DocumentBuilder} from 'firebase-functions/lib/providers/firestore'; | |
type httpsOnRequestHandler = Parameters<typeof onRequest>[0]; | |
type httpsOnCallHandler = Parameters<typeof onCall>[0]; | |
type pubsubOnRunHandler = Parameters<ScheduleBuilder['onRun']>[0]; | |
type firestoreOnWriteHandler = Parameters<DocumentBuilder['onWrite']>[0]; | |
type firestoreOnUpdateHandler = Parameters<DocumentBuilder['onUpdate']>[0]; | |
type firestoreOnCreateHandler = Parameters<DocumentBuilder['onCreate']>[0]; | |
type firestoreOnDeleteHandler = Parameters<DocumentBuilder['onDelete']>[0]; | |
type FunctionType = 'http' | 'callable' | 'document' | 'schedule'; | |
export function getLocationHeaders(req: https.Request): {country?: string; ip?: string} { | |
/** | |
* Checking order: | |
* Cloudflare: in case user is proxying functions through it | |
* Fastly: in case user is service functions through firebase hosting (Fastly is the default Firebase CDN) | |
* App Engine: in case user is serving functions directly through cloudfunctions.net | |
*/ | |
const ip = | |
req.header('Cf-Connecting-Ip') || | |
req.header('Fastly-Client-Ip') || | |
req.header('X-Appengine-User-Ip') || | |
req.header('X-Forwarded-For')?.split(',')[0] || | |
req.connection.remoteAddress || | |
req.socket.remoteAddress; | |
const country = | |
req.header('Cf-Ipcountry') || | |
req.header('X-Country-Code') || | |
req.header('X-Appengine-Country'); | |
return {ip: ip?.toString(), country: country?.toString()}; | |
} | |
function wrap<A, C>(type: FunctionType, name: string, fn: (a: A) => C | Promise<C>): typeof fn; | |
function wrap<A, B, C>( | |
type: FunctionType, | |
name: string, | |
fn: (a: A, b: B) => C | Promise<C> | |
): typeof fn; | |
function wrap<A, B, C>( | |
type: FunctionType, | |
name: string, | |
fn: (a: A, b: B) => C | Promise<C> | |
): typeof fn { | |
return async (a: A, b: B): Promise<C> => { | |
const {startTransaction, configureScope, Handlers, captureException, flush} = await import( | |
'@sentry/node' | |
); | |
const {extractTraceparentData} = await import('@sentry/tracing'); | |
let req: https.Request | undefined; | |
let ctx: Record<string, unknown> | undefined; | |
if (type === 'http') { | |
req = (a as unknown) as https.Request; | |
} | |
if (type === 'callable') { | |
const ctxLocal = (b as unknown) as https.CallableContext; | |
req = ctxLocal.rawRequest; | |
} | |
if (type === 'document') { | |
ctx = (b as unknown) as Record<string, unknown>; | |
} | |
if (type === 'schedule') { | |
ctx = (a as unknown) as Record<string, unknown>; | |
} | |
const traceparentData = extractTraceparentData(req?.header('sentry-trace') || ''); | |
const transaction = startTransaction({ | |
name, | |
op: 'transaction', | |
...traceparentData, | |
}); | |
configureScope(scope => { | |
scope.addEventProcessor(event => { | |
let ev: Event = event; | |
if (req) { | |
ev = Handlers.parseRequest(event, req); | |
const loc = getLocationHeaders(req); | |
loc.ip && Object.assign(ev.user, {ip_address: loc.ip}); | |
loc.country && Object.assign(ev.user, {country: loc.country}); | |
} | |
if (ctx) { | |
ev = Handlers.parseRequest(event, ctx); | |
ev.extra = ctx; | |
delete ev.request; | |
} | |
ev.transaction = transaction.name; | |
// force catpuring uncaughtError as not handled | |
const mechanism = ev.exception?.values?.[0].mechanism; | |
if (mechanism && ev.tags?.handled === false) { | |
mechanism.handled = false; | |
} | |
return ev; | |
}); | |
scope.setSpan(transaction); | |
}); | |
return Promise.resolve(fn(a, b)) | |
.catch(err => { | |
captureException(err, {tags: {handled: false}}); | |
throw err; | |
}) | |
.finally(() => { | |
transaction.finish(); | |
return flush(2000); | |
}); | |
}; | |
} | |
export function wrapHttpsOnRequestHandler(name: string, fn: httpsOnRequestHandler): typeof fn { | |
return wrap('http', name, fn); | |
} | |
export function wrapHttpsOnCallHandler(name: string, fn: httpsOnCallHandler): typeof fn { | |
return wrap('callable', name, fn); | |
} | |
export function wrapPubsubOnRunHandler(name: string, fn: pubsubOnRunHandler): typeof fn { | |
return wrap('schedule', name, fn); | |
} | |
export function wrapFirestoreOnWriteHandler(name: string, fn: firestoreOnWriteHandler): typeof fn { | |
return wrap('document', name, fn); | |
} | |
export function wrapFirestoreOnUpdateHandler( | |
name: string, | |
fn: firestoreOnUpdateHandler | |
): typeof fn { | |
return wrap('document', name, fn); | |
} | |
export function wrapFirestoreOnCreateHandler( | |
name: string, | |
fn: firestoreOnCreateHandler | |
): typeof fn { | |
return wrap('document', name, fn); | |
} | |
export function wrapFirestoreOnDeleteHandler( | |
name: string, | |
fn: firestoreOnDeleteHandler | |
): typeof fn { | |
return wrap('document', name, fn); | |
} |
Hi, @zeevl. Sure, I just updated to the latest version I'm using which adds IP and Country data to the context.
Types are also embedded now.
Thank you!!!
Thanks for posting this. I asked about firebase coverage last week in Sentry's Discord community and the response I got was "We don't have it scheduled, but our PRs are open".
To sum up the benefits over merely wrapping with captureException
:
- transactions
- spans
- distributed tracing
- request context
- typing
Question: why are @sentry/node
and @sentry/tracing
imported dynamically here https://gist.github.com/zanona/0f3d42093eaa8ac5c33286cc7eca1166#file-sentry-serverless-firebase-ts-L56 ?
Hi, @gregdingle. I'm glad you found it useful.
In regard to dynamic imports, I do remember that at the time, static imports were affecting cold start times and, as I had some other functions which didn't need to be covered by sentry, I decided to lazily import those dependencies.
https://youtu.be/v3eG9xpzNXM?t=174
Great job 💪
Am I right btw? Or it was a typo and we have to remove
const
?
@n-sviridenko Well spotted! I think your solution with ctxLocal
is pretty good. I just updated the gist.
Hi @zanona, Great work!
I'm not sure but I think line #115 is unnecessary since it causes to fail every request when Sentry starts to throw 429 Errors (due to rate-limiting or plan limit reached).
@abdulaziz-mohammed thanks for the feedback.
Would you be able to think of any other implications while removing line 115?
At the moment it comes to mind that, if we don't throw an error, those will never be signalled on Firebase console? Causing all executions finish successfully?
I might be wrong, though. What do you think?
Wow! Amazing!
But I am getting this error:
TS2339: Property 'finally' does not exist on type 'Promise'.
I am using typescript 3.9.10
@razbakov import '@sentry/tracing'; and place "SentryTracing.addExtensionMethods();" in the code.
@zanona or anyone else, have you tried porting these ideas over to cloud functions v2? I would love to use them there as well.
Hey, @abierbaum. I haven't yet used v2 functions. Would you happen to know what have changed from the previous implementation which would prevent this to work?
I have updated this gist removing deprecations and avoiding the wrap in localhost.
https://gist.github.com/JFGHT/32cb01e9b3e842579dd2cc2741d2033e
Awesome work @JFGHT! Happy new Year!
Hey, @abierbaum. I haven't yet used v2 functions. Would you happen to know what have changed from the previous implementation which would prevent this to work?
@zanona different library, different method signatures:
import {onRequest} from "firebase-functions/v2/https";
This is great, thank you for posting!!
Any chance you could include the source for
../types
as well?