Repository navigation
feat(node,aws-serverless): Answer managed tunnel requests on Lambda #26000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
logaretm
merged 19 commits into
awad/runtime-tunnel-cloudflare
from
awad/runtime-tunnel-lambda
Nov 6, 2026
+537
−13
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
6390ae7
feat(node,aws-serverless): Answer managed tunnel requests on Lambda
logaretm e74ae1c
test(bundler-tests): Classify the `@sentry/node/run-lambda-handler` e…
logaretm ccb7436
ref(node): Name the Lambda tunnel event handlers handle*TunnelEvent
logaretm 47a5dd9
ref(node): Build the Lambda tunnel integration from the server-utils …
logaretm f77208b
fix(node): Recognize the tunnel-only Lambda shim by a marker instead …
logaretm 6d18ceb
test(aws-serverless): Answer managed tunnel requests on a real Lambda…
logaretm 039d30e
fix(node): Resolve the Lambda tunnel shim without `import.meta.url`
logaretm 972c49b
ref(tanstackstart-react): Explain why the Lambda handler is redirecte…
logaretm 24250cb
fix(node): Pass on only the forwarded headers of Sentry's answer on L…
logaretm b57b81b
fix(node): End the Lambda tunnel stream when Sentry's answer breaks off
logaretm 06104ee
fix(node): Warn when the Lambda tunnel can't redirect the handler
logaretm 2515607
ref(node): Type the Lambda streaming handler symbols instead of doubl…
logaretm d85d358
test(node): Don't expect a silent startup from a bundled Lambda SDK
logaretm ae14e1b
fix(node): Match REST API tunnel requests on the path with the stage
logaretm 62617db
fix(node): Always print the warning that the Lambda tunnel can't redi…
logaretm 357b058
fix(node): Claim a REST API tunnel request if either of its paths mat…
logaretm 7bf449f
test(aws-serverless): Answer a REST API tunnel request when only its …
logaretm a6450ee
ref(node,aws-serverless): Answer Lambda tunnel requests in @sentry/aw…
logaretm 417ccc7
fix(aws-serverless): Resolve the Lambda handler shim without referenc…
logaretm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
1 change: 1 addition & 0 deletions
1
...e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/index.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const handler = async () => ({ statusCode: 200, body: 'app' }); |
7 changes: 7 additions & 0 deletions
7
...ests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/instrument.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import * as Sentry from '@sentry/aws-serverless'; | ||
|
|
||
| Sentry.init({ | ||
| dsn: process.env.SENTRY_DSN, | ||
| debug: true, | ||
| integrations: [Sentry.serveTunnelIntegration()], | ||
| }); |
3 changes: 3 additions & 0 deletions
3
...e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelCallback/index.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| exports.handler = (_event, _context, callback) => { | ||
| callback(null, { statusCode: 200, body: 'app' }); | ||
| }; |
7 changes: 7 additions & 0 deletions
7
...s/test-applications/aws-serverless/src/lambda-functions-npm/TunnelCallback/instrument.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import * as Sentry from '@sentry/aws-serverless'; | ||
|
|
||
| Sentry.init({ | ||
| dsn: process.env.SENTRY_DSN, | ||
| debug: true, | ||
| integrations: [Sentry.serveTunnelIntegration()], | ||
| }); |
1 change: 1 addition & 0 deletions
1
...tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStagePath/index.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const handler = async () => ({ statusCode: 200, body: 'app' }); |
8 changes: 8 additions & 0 deletions
8
.../test-applications/aws-serverless/src/lambda-functions-npm/TunnelStagePath/instrument.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import * as Sentry from '@sentry/aws-serverless'; | ||
|
|
||
| Sentry.init({ | ||
| dsn: process.env.SENTRY_DSN, | ||
| debug: true, | ||
| // Served on an API Gateway stage named `prod`, where the browser posts to `/prod/<path>`. | ||
| integrations: [Sentry.serveTunnelIntegration({ basePath: '/prod' })], | ||
| }); |
4 changes: 4 additions & 0 deletions
4
...tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/index.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export const handler = awslambda.streamifyResponse(async (_event, responseStream) => { | ||
| responseStream.write('app'); | ||
| responseStream.end(); | ||
| }); |
7 changes: 7 additions & 0 deletions
7
.../test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/instrument.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import * as Sentry from '@sentry/aws-serverless'; | ||
|
|
||
| Sentry.init({ | ||
| dsn: process.env.SENTRY_DSN, | ||
| debug: true, | ||
| integrations: [Sentry.serveTunnelIntegration()], | ||
| }); |
4 changes: 4 additions & 0 deletions
4
...pplications/aws-serverless/src/lambda-functions-npm/TunnelStreamingBrokenAnswer/index.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export const handler = awslambda.streamifyResponse(async (_event, responseStream) => { | ||
| responseStream.write('app'); | ||
| responseStream.end(); | ||
| }); |
8 changes: 8 additions & 0 deletions
8
...ations/aws-serverless/src/lambda-functions-npm/TunnelStreamingBrokenAnswer/instrument.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import * as Sentry from '@sentry/aws-serverless'; | ||
|
|
||
| Sentry.init({ | ||
| // The test's ingest on this port answers 200 and then breaks off mid-body. | ||
| dsn: process.env.SENTRY_DSN.replace(':3031/', ':3033/'), | ||
| debug: true, | ||
| integrations: [Sentry.serveTunnelIntegration()], | ||
| }); |
4 changes: 4 additions & 0 deletions
4
...applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingUnreachable/index.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export const handler = awslambda.streamifyResponse(async (_event, responseStream) => { | ||
| responseStream.write('app'); | ||
| responseStream.end(); | ||
| }); |
8 changes: 8 additions & 0 deletions
8
...cations/aws-serverless/src/lambda-functions-npm/TunnelStreamingUnreachable/instrument.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import * as Sentry from '@sentry/aws-serverless'; | ||
|
|
||
| Sentry.init({ | ||
| // Nothing listens on this port, so forwarding a tunnel request fails after the tunnel claimed it. | ||
| dsn: process.env.SENTRY_DSN.replace(':3031/', ':3032/'), | ||
| debug: true, | ||
| integrations: [Sentry.serveTunnelIntegration()], | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
261 changes: 261 additions & 0 deletions
261
dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,261 @@ | ||
| import { InvokeCommand } from '@aws-sdk/client-lambda'; | ||
| import type { LambdaClient } from '@aws-sdk/client-lambda'; | ||
| import { waitForError } from '@sentry-internal/test-utils'; | ||
| import { createServer } from 'node:http'; | ||
| import { getTunnelPath } from '@sentry/aws-serverless'; | ||
| import { getHostIp } from '../src/stack'; | ||
| import { expect, test } from './lambda-fixtures'; | ||
|
|
||
| /** | ||
| * The headers of Sentry's answer the Node tunnel passes on, see `FORWARDED_RESPONSE_HEADERS` in `@sentry/core`. | ||
| */ | ||
| const FORWARDED_RESPONSE_HEADERS = ['content-type', 'retry-after', 'x-sentry-rate-limits']; | ||
|
|
||
| interface InvokeResult { | ||
| functionError?: string; | ||
| payload: string; | ||
| } | ||
|
|
||
| async function invoke(lambdaClient: LambdaClient, functionName: string, event: unknown): Promise<InvokeResult> { | ||
| const result = await lambdaClient.send( | ||
| new InvokeCommand({ FunctionName: functionName, Payload: JSON.stringify(event) }), | ||
| ); | ||
|
|
||
| return { functionError: result.FunctionError, payload: Buffer.from(result.Payload ?? []).toString('utf8') }; | ||
| } | ||
|
|
||
| async function getDsn(port = 3031): Promise<string> { | ||
| return `http://public@${await getHostIp()}:${port}/1337`; | ||
| } | ||
|
|
||
| function makeEnvelope(dsn: string, marker: string): string { | ||
| const eventId = crypto.randomUUID().replace(/-/g, ''); | ||
|
|
||
| return [ | ||
| JSON.stringify({ dsn, event_id: eventId, sent_at: new Date().toISOString() }), | ||
| JSON.stringify({ type: 'event' }), | ||
| JSON.stringify({ event_id: eventId, message: marker, level: 'info' }), | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| type EventShape = 'apiGatewayV2' | 'apiGatewayV1' | 'alb'; | ||
|
|
||
| /** | ||
| * The event API Gateway (HTTP API or function URL, and REST), or an ALB, hands the handler for a browser POSTing an | ||
| * envelope to the tunnel. | ||
| */ | ||
| function makeHttpEvent(shape: EventShape, path: string, envelope: string, base64 = false): Record<string, unknown> { | ||
| const body = base64 ? Buffer.from(envelope).toString('base64') : envelope; | ||
| const contentType = 'application/x-sentry-envelope'; | ||
|
|
||
| if (shape === 'apiGatewayV2') { | ||
| return { | ||
| version: '2.0', | ||
| rawPath: path, | ||
| rawQueryString: '', | ||
| headers: { 'content-type': contentType }, | ||
| requestContext: { http: { method: 'POST' } }, | ||
| body, | ||
| isBase64Encoded: base64, | ||
| }; | ||
| } | ||
|
|
||
| if (shape === 'apiGatewayV1') { | ||
| return { httpMethod: 'POST', path, headers: { 'content-type': contentType }, body, isBase64Encoded: base64 }; | ||
| } | ||
|
|
||
| return { | ||
| httpMethod: 'POST', | ||
| path, | ||
| multiValueHeaders: { 'content-type': [contentType] }, | ||
| requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:us-east-1:012345678912:targetgroup/t' } }, | ||
| body, | ||
| isBase64Encoded: base64, | ||
| }; | ||
| } | ||
|
|
||
| test.describe('managed tunnel', () => { | ||
| for (const functionName of ['NpmTunnelAsync', 'NpmTunnelCallback']) { | ||
| test(`${functionName} answers tunnel requests before the handler`, async ({ lambdaClient }) => { | ||
| const dsn = await getDsn(); | ||
| const marker = `${functionName}-${crypto.randomUUID()}`; | ||
| const forwarded = waitForError('aws-serverless', event => event.message === marker); | ||
|
|
||
| const result = await invoke( | ||
| lambdaClient, | ||
| functionName, | ||
| makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, marker)), | ||
| ); | ||
|
|
||
| expect(result.functionError).toBeUndefined(); | ||
| expect(JSON.parse(result.payload)).toMatchObject({ statusCode: 200, isBase64Encoded: false }); | ||
| expect(JSON.parse(result.payload).body).not.toBe('app'); | ||
| await forwarded; | ||
| }); | ||
|
|
||
| test(`${functionName} passes other events to the handler`, async ({ lambdaClient }) => { | ||
| const result = await invoke( | ||
| lambdaClient, | ||
| functionName, | ||
| makeHttpEvent('apiGatewayV2', '/other', 'not an envelope'), | ||
| ); | ||
|
|
||
| expect(JSON.parse(result.payload)).toEqual({ statusCode: 200, body: 'app' }); | ||
| }); | ||
| } | ||
|
|
||
| // An ALB event with multi-value headers needs multi-value headers back. | ||
| for (const [shape, base64, headersKey] of [ | ||
| ['apiGatewayV1', false, 'headers'], | ||
| ['alb', false, 'multiValueHeaders'], | ||
| ['apiGatewayV2', true, 'headers'], | ||
| ] as const) { | ||
| test(`reads ${base64 ? 'a base64 body from ' : ''}an ${shape} event`, async ({ lambdaClient }) => { | ||
| const dsn = await getDsn(); | ||
| const marker = `${shape}-${base64}-${crypto.randomUUID()}`; | ||
| const forwarded = waitForError('aws-serverless', event => event.message === marker); | ||
|
|
||
| const result = await invoke( | ||
| lambdaClient, | ||
| 'NpmTunnelAsync', | ||
| makeHttpEvent(shape, getTunnelPath(dsn)!, makeEnvelope(dsn, marker), base64), | ||
| ); | ||
|
|
||
| const response = JSON.parse(result.payload); | ||
| expect(response.statusCode).toBe(200); | ||
| expect(response).toHaveProperty(headersKey); | ||
| // Only the headers the browser SDK reads, like the Node answer, not the ones about the connection to Sentry. | ||
| const headerNames = Object.keys(response[headersKey]); | ||
| expect(headerNames.filter(name => !FORWARDED_RESPONSE_HEADERS.includes(name))).toEqual([]); | ||
| await forwarded; | ||
| }); | ||
| } | ||
|
|
||
| for (const functionName of ['NpmTunnelStreaming']) { | ||
| test(`${functionName} streams the tunnel answer before the handler`, async ({ lambdaClient }) => { | ||
| const dsn = await getDsn(); | ||
| const marker = `${functionName}-${crypto.randomUUID()}`; | ||
| const forwarded = waitForError('aws-serverless', event => event.message === marker); | ||
|
|
||
| const result = await invoke( | ||
| lambdaClient, | ||
| functionName, | ||
| makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, marker)), | ||
| ); | ||
|
|
||
| expect(result.functionError).toBeUndefined(); | ||
| expect(result.payload).not.toContain('app'); | ||
| await forwarded; | ||
| }); | ||
| } | ||
|
|
||
| test('ends the stream with the error answer when forwarding a tunnel request fails', async ({ lambdaClient }) => { | ||
| // The function's DSN points at a port nothing listens on. | ||
| const dsn = await getDsn(3032); | ||
| const startedAt = Date.now(); | ||
|
|
||
| const result = await invoke( | ||
| lambdaClient, | ||
| 'NpmTunnelStreamingUnreachable', | ||
| makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, 'unreachable')), | ||
| ); | ||
|
|
||
| // An unended stream would hold the invocation until the 10s function timeout. | ||
| expect(result.functionError).toBeUndefined(); | ||
| expect(Date.now() - startedAt).toBeLessThan(8_000); | ||
| expect(result.payload).toBe('Failed to forward envelope to Sentry'); | ||
| }); | ||
|
|
||
| test('ends the stream with an error answer when the answer from Sentry breaks off', async ({ lambdaClient }) => { | ||
| // Answers 200, then drops the connection before the body is complete. | ||
| const ingest = createServer((req, res) => { | ||
| req.resume(); | ||
| req.on('end', () => { | ||
| res.writeHead(200, { 'content-type': 'application/json', 'content-length': '100' }); | ||
| res.write('{"id":'); | ||
| setTimeout(() => res.socket?.destroy(), 50); | ||
| }); | ||
| }).listen(3033, '0.0.0.0'); | ||
|
|
||
| try { | ||
| const dsn = await getDsn(3033); | ||
| const startedAt = Date.now(); | ||
|
|
||
| const result = await invoke( | ||
| lambdaClient, | ||
| 'NpmTunnelStreamingBrokenAnswer', | ||
| makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, 'broken-answer')), | ||
| ); | ||
|
|
||
| // An unended stream would hold the invocation until the 10s function timeout. | ||
| expect(result.functionError).toBeUndefined(); | ||
| expect(Date.now() - startedAt).toBeLessThan(8_000); | ||
| expect(result.payload).not.toContain('app'); | ||
| } finally { | ||
| ingest.close(); | ||
| } | ||
| }); | ||
|
|
||
| // The shapes a real API Gateway sends on a stage named `prod`: a REST API leaves the stage out of `path` but keeps it | ||
| // in `requestContext.path`, an HTTP API keeps it in `rawPath`. | ||
| for (const [api, makeStageEvent] of [ | ||
| [ | ||
| 'REST API', | ||
| (tunnelPath: string, envelope: string) => ({ | ||
| httpMethod: 'POST', | ||
| path: tunnelPath, | ||
| headers: { 'content-type': 'application/x-sentry-envelope' }, | ||
| requestContext: { path: `/prod${tunnelPath}`, stage: 'prod' }, | ||
| body: envelope, | ||
| isBase64Encoded: false, | ||
| }), | ||
| ], | ||
| [ | ||
| 'HTTP API', | ||
| (tunnelPath: string, envelope: string) => ({ | ||
| version: '2.0', | ||
| rawPath: `/prod${tunnelPath}`, | ||
| rawQueryString: '', | ||
| headers: { 'content-type': 'application/x-sentry-envelope' }, | ||
| requestContext: { http: { method: 'POST' }, stage: 'prod' }, | ||
| body: envelope, | ||
| isBase64Encoded: false, | ||
| }), | ||
| ], | ||
| ] as const) { | ||
| test(`answers tunnel requests on a named ${api} stage with the stage as \`basePath\``, async ({ lambdaClient }) => { | ||
| const dsn = await getDsn(); | ||
| const marker = `stage-${api}-${crypto.randomUUID()}`; | ||
| const forwarded = waitForError('aws-serverless', event => event.message === marker); | ||
|
|
||
| const result = await invoke( | ||
| lambdaClient, | ||
| 'NpmTunnelStagePath', | ||
| makeStageEvent(getTunnelPath(dsn)!, makeEnvelope(dsn, marker)), | ||
| ); | ||
|
|
||
| expect(JSON.parse(result.payload).body).not.toBe('app'); | ||
| await forwarded; | ||
| }); | ||
| } | ||
|
|
||
| // Where the browser posted to the stage-stripped `path`, like a custom domain mapped to the stage. | ||
| test('answers a REST API tunnel request when only its stage-stripped path matches', async ({ lambdaClient }) => { | ||
| const dsn = await getDsn(); | ||
| const marker = `stage-stripped-${crypto.randomUUID()}`; | ||
| const forwarded = waitForError('aws-serverless', event => event.message === marker); | ||
| const tunnelPath = getTunnelPath(dsn)!; | ||
|
|
||
| const result = await invoke(lambdaClient, 'NpmTunnelAsync', { | ||
| httpMethod: 'POST', | ||
| path: tunnelPath, | ||
| headers: { 'content-type': 'application/x-sentry-envelope' }, | ||
| requestContext: { path: `/prod${tunnelPath}`, stage: 'prod' }, | ||
| body: makeEnvelope(dsn, marker), | ||
| isBase64Encoded: false, | ||
| }); | ||
|
|
||
| expect(JSON.parse(result.payload).body).not.toBe('app'); | ||
| await forwarded; | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.