Skip to content

Commit 129104a

Browse files
committed
Add Forge runtime transport
1 parent 4a10423 commit 129104a

File tree

4 files changed

+261
-91
lines changed

4 files changed

+261
-91
lines changed

packages/node/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"devDependencies": {
3030
"@sentry-internal/eslint-config-sdk": "6.7.1",
31+
"@forge/api": "^2.0.1",
3132
"@types/cookie": "0.3.2",
3233
"@types/express": "^4.17.2",
3334
"@types/lru-cache": "^5.1.0",

packages/node/src/transports/forge.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { FetchMethod as ForgeRuntimeFetchMethod } from '@forge/api';
2+
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
3+
import { Event, Response, Session, SessionAggregates, TransportOptions } from '@sentry/types';
4+
import http, { OutgoingHttpHeaders } from 'http';
5+
import https from 'https';
6+
import { URL } from 'url';
7+
8+
import { BaseTransport } from './base';
9+
import {
10+
HTTPModule,
11+
HTTPModuleClientRequest,
12+
HTTPModuleRequestIncomingMessage,
13+
HTTPModuleRequestOptions,
14+
} from './base/http-module';
15+
16+
export type ErrorLogger = (map: { err: Error }, msg: string) => void;
17+
18+
export type ForgeRuntimeTransportOptions = TransportOptions & {
19+
fetch: ForgeRuntimeFetchMethod;
20+
logError: ErrorLogger;
21+
};
22+
23+
type ForgeHttpModuleRequestOptions = {
24+
logError: ErrorLogger;
25+
fetch: ForgeRuntimeFetchMethod;
26+
options: http.RequestOptions | https.RequestOptions;
27+
callback(res: HTTPModuleRequestIncomingMessage): void;
28+
};
29+
30+
/** Forge fetch implementation only accepts simple headers object */
31+
const isValidHeadersObject = (headers: OutgoingHttpHeaders): headers is { [key: string]: string } => {
32+
return Object.keys(headers).every(headerName => {
33+
const value = headers[headerName];
34+
return typeof value === 'string';
35+
});
36+
};
37+
38+
/** Internal Error used to re-throw if Response.ok is false */
39+
class InvalidResponseError extends Error {
40+
public constructor(public statusCode: number) {
41+
super('Unexpected response code');
42+
}
43+
}
44+
45+
/**
46+
* Current tsconfig target is "es5" which transforms classes into functions.
47+
* Thus, "instanceof" check doesn't work and that's the reason why this type guard function exists.
48+
*/
49+
const isInvalidResponseError = (err: Error | InvalidResponseError): err is InvalidResponseError => {
50+
return 'statusCode' in err;
51+
};
52+
53+
/**
54+
* Forge custom implementation of http.ClientRequest
55+
*
56+
* It mimics Node.JS behaviour because Forge runtime has limited Node.JS API support.
57+
* @see https://developer.atlassian.com/platform/forge/runtime-reference/#javascript-environment
58+
*/
59+
class ForgeHttpModuleRequest implements HTTPModuleClientRequest {
60+
private readonly _httpRes: Exclude<HTTPModuleRequestIncomingMessage, 'statusCode'>;
61+
62+
public constructor(private _options: ForgeHttpModuleRequestOptions) {
63+
this._httpRes = {
64+
setEncoding: () => null,
65+
headers: {},
66+
on: () => null,
67+
};
68+
}
69+
70+
/** Mock method because it's not needed for this module */
71+
public on(): void {
72+
return undefined;
73+
}
74+
75+
/** Sends request to Sentry API */
76+
public async end(body: string): Promise<void> {
77+
// eslint-disable-next-line @typescript-eslint/unbound-method
78+
const {
79+
callback,
80+
logError,
81+
fetch,
82+
options: { headers, method, path, protocol, hostname, port },
83+
} = this._options;
84+
const url = new URL(path || '/', 'https://example.com');
85+
if (protocol) {
86+
url.protocol = protocol;
87+
}
88+
if (hostname) {
89+
url.hostname = hostname;
90+
}
91+
if (port) {
92+
url.port = String(port);
93+
}
94+
95+
const requestHeaders = headers && isValidHeadersObject(headers) ? headers : {};
96+
97+
try {
98+
const res = await fetch(url.toString(), {
99+
body,
100+
method,
101+
headers: requestHeaders,
102+
});
103+
104+
if (!res.ok) {
105+
throw new InvalidResponseError(res.status);
106+
}
107+
108+
callback({
109+
...this._httpRes,
110+
statusCode: res.status,
111+
});
112+
} catch (err) {
113+
// eslint-disable-next-line no-console
114+
logError({ err }, 'Fetching entry API failed');
115+
116+
const statusCode = isInvalidResponseError(err) ? err.statusCode : 500;
117+
118+
callback({
119+
...this._httpRes,
120+
statusCode,
121+
});
122+
}
123+
}
124+
}
125+
126+
/**
127+
* HTTPModule implementation for Forge runtime.
128+
* It mimics Node.JS behaviour because Forge runtime has limited Node.JS API support.
129+
*
130+
* @see https://developer.atlassian.com/platform/forge/runtime-reference/#javascript-environment
131+
*/
132+
class ForgeHttpModule implements HTTPModule {
133+
public constructor(private _forgeFetch: ForgeRuntimeFetchMethod, private _logError: ErrorLogger) {}
134+
135+
/** Sends request to Sentry API */
136+
public request(
137+
options: HTTPModuleRequestOptions,
138+
callback: (res: HTTPModuleRequestIncomingMessage) => void,
139+
): HTTPModuleClientRequest {
140+
if (typeof options === 'string') {
141+
throw new Error('String request options are not supported');
142+
}
143+
144+
if (options instanceof URL) {
145+
throw new Error('URL as request options is not supported');
146+
}
147+
148+
return new ForgeHttpModuleRequest({
149+
options,
150+
callback,
151+
fetch: this._forgeFetch,
152+
logError: this._logError,
153+
});
154+
}
155+
}
156+
157+
/** Forge module transport */
158+
export class ForgeRuntimeTransport extends BaseTransport {
159+
/** Create a new instance and set this.agent */
160+
public constructor(options: ForgeRuntimeTransportOptions) {
161+
super(options);
162+
163+
this.module = new ForgeHttpModule(options.fetch, options.logError);
164+
this.client = undefined;
165+
this.urlParser = url => new URL(url);
166+
}
167+
168+
/**
169+
* @inheritDoc
170+
*/
171+
public sendEvent(event: Event): Promise<Response> {
172+
return this._send(eventToSentryRequest(event, this._api), event);
173+
}
174+
175+
/**
176+
* @inheritDoc
177+
*/
178+
public sendSession(session: Session | SessionAggregates): PromiseLike<Response> {
179+
return this._send(sessionToSentryRequest(session, this._api), session);
180+
}
181+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { SentryError } from '@sentry/utils';
2+
3+
import { ForgeRuntimeTransport } from '../../src/transports/forge';
4+
5+
const dsn = 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622';
6+
const silenceErrors = () => null;
7+
8+
describe('ForgeRuntimeTransport', () => {
9+
test('calls Forge runtime API fetch()', async () => {
10+
const requestSpy = jest.fn().mockResolvedValue({
11+
ok: true,
12+
status: 200,
13+
});
14+
const transport = new ForgeRuntimeTransport({ dsn, fetch: requestSpy, logError: silenceErrors });
15+
await transport.sendEvent({});
16+
expect(requestSpy).toHaveBeenCalled();
17+
});
18+
19+
test('rejects if Forge runtime API fetch() rejects', () => {
20+
const requestSpy = jest.fn().mockRejectedValue(new Error('Fetch error to console.error'));
21+
const transport = new ForgeRuntimeTransport({ dsn, fetch: requestSpy, logError: silenceErrors });
22+
23+
return expect(transport.sendEvent({})).rejects.toEqual(new SentryError('HTTP Error (500)'));
24+
});
25+
26+
test('rejects if API response is not ok', () => {
27+
const requestSpy = jest.fn().mockResolvedValue({
28+
ok: false,
29+
status: 400,
30+
});
31+
const transport = new ForgeRuntimeTransport({ dsn, fetch: requestSpy, logError: silenceErrors });
32+
return expect(transport.sendEvent({})).rejects.toEqual(new SentryError('HTTP Error (400)'));
33+
});
34+
});

0 commit comments

Comments
 (0)