|
1 |
| -import { Scope, Severity } from '@sentry/types'; |
2 |
| -import deepMerge from 'deepmerge'; |
| 1 | +import { |
| 2 | + ApolloLink, |
| 3 | + FetchResult, |
| 4 | + NextLink, |
| 5 | + Operation, |
| 6 | +} from '@apollo/client/core'; |
| 7 | +import { Severity } from '@sentry/types'; |
3 | 8 | import Observable from 'zen-observable';
|
4 | 9 |
|
5 |
| -import { FetchResult } from '@apollo/client/link/core/types'; |
6 |
| - |
| 10 | +import { GraphQLBreadcrumb, makeBreadcrumb } from './breadcrumb'; |
| 11 | +import { FullOptions, SentryLinkOptions, withDefaults } from './options'; |
7 | 12 | import {
|
8 |
| - ApolloLink, NextLink, Operation as ApolloOperation, |
9 |
| -} from '@apollo/client/link/core'; |
10 |
| - |
11 |
| -import { addBreadcrumb, configureScope } from '@sentry/minimal'; |
12 |
| -import { OperationBreadcrumb } from './OperationBreadcrumb'; |
13 |
| -import { Operation } from './Operation'; |
14 |
| - |
15 |
| -export interface Options { |
16 |
| - setTransaction?: boolean; |
17 |
| - setFingerprint?: boolean; |
18 |
| - |
19 |
| - breadcrumb?: { |
20 |
| - enable?: boolean; |
21 |
| - includeQuery?: boolean; |
22 |
| - includeCache?: boolean; |
23 |
| - includeVariables?: boolean; |
24 |
| - includeResponse?: boolean; |
25 |
| - includeError?: boolean; |
26 |
| - includeContextKeys?: string[]; |
27 |
| - } |
28 |
| - |
29 |
| - filter?: (operation: Operation) => boolean; |
30 |
| - beforeBreadcrumb?: (breadcrumb: OperationBreadcrumb) => OperationBreadcrumb; |
31 |
| -} |
32 |
| - |
33 |
| -const defaultOptions: Options = { |
34 |
| - setTransaction: true, |
35 |
| - setFingerprint: true, |
36 |
| - |
37 |
| - breadcrumb: { |
38 |
| - enable: true, |
39 |
| - includeQuery: false, |
40 |
| - includeCache: false, |
41 |
| - includeVariables: false, |
42 |
| - includeResponse: false, |
43 |
| - includeError: false, |
44 |
| - includeContextKeys: [], |
45 |
| - }, |
46 |
| -}; |
| 13 | + attachBreadcrumbToSentry, |
| 14 | + setFingerprint, |
| 15 | + setTransaction, |
| 16 | +} from './sentry'; |
47 | 17 |
|
48 | 18 | export class SentryLink extends ApolloLink {
|
49 |
| - private readonly options: Options; |
| 19 | + private readonly options: FullOptions; |
50 | 20 |
|
51 |
| - /** |
52 |
| - * Create a new ApolloLinkSentry |
53 |
| - * @param {Options} options |
54 |
| - */ |
55 |
| - constructor(options: Options = {}) { |
| 21 | + constructor(options: SentryLinkOptions = {}) { |
56 | 22 | super();
|
57 |
| - this.options = deepMerge(defaultOptions, options); |
| 23 | + this.options = withDefaults(options); |
58 | 24 | }
|
59 | 25 |
|
60 |
| - /** |
61 |
| - * This is where the GraphQL operation is received |
62 |
| - * A breadcrumb will be created for the operation, and error/response data will be handled |
63 |
| - * @param {ApolloOperation} op |
64 |
| - * @param {NextLink} forward |
65 |
| - * @returns {Observable<FetchResult> | null} |
66 |
| - */ |
67 |
| - request = (op: ApolloOperation, forward: NextLink): Observable<FetchResult> | null => { |
68 |
| - // Obtain necessary data from the operation |
69 |
| - const operation = new Operation(op); |
70 |
| - |
71 |
| - // Create a new breadcrumb for this specific operation |
72 |
| - const breadcrumb = new OperationBreadcrumb(); |
73 |
| - this.fillBreadcrumb(breadcrumb, operation); |
74 |
| - |
75 |
| - // Start observing the operation for results |
76 |
| - return new Observable<FetchResult>((observer) => { |
77 |
| - const subscription = forward(op).subscribe({ |
78 |
| - next: (result: FetchResult) => this.handleResult(result, breadcrumb, observer), |
79 |
| - complete: () => this.handleComplete(breadcrumb, observer), |
80 |
| - error: (error: any) => this.handleError(breadcrumb, error, observer), |
81 |
| - }); |
82 |
| - |
83 |
| - // Close the subscription |
84 |
| - return () => { |
85 |
| - if (subscription) subscription.unsubscribe(); |
86 |
| - }; |
87 |
| - }); |
88 |
| - }; |
89 |
| - |
90 |
| - /** |
91 |
| - * Fill the breadcrumb with information, respecting the provided options |
92 |
| - * The breadcrumb is not yet attached to Sentry after this method |
93 |
| - * @param {OperationBreadcrumb} breadcrumb |
94 |
| - * @param {Operation} operation |
95 |
| - */ |
96 |
| - fillBreadcrumb = (breadcrumb: OperationBreadcrumb, operation: Operation): void => { |
97 |
| - // Apply the filter option |
98 |
| - if (typeof this.options.filter === 'function') { |
99 |
| - const stop = breadcrumb.filter(this.options.filter(operation)); |
100 |
| - if (stop) return; |
| 26 | + request( |
| 27 | + operation: Operation, |
| 28 | + forward: NextLink, |
| 29 | + ): Observable<FetchResult> | null { |
| 30 | + if (typeof this.options.shouldHandleOperation === 'function') { |
| 31 | + if (!this.options.shouldHandleOperation(operation)) { |
| 32 | + return forward(operation); |
| 33 | + } |
101 | 34 | }
|
102 | 35 |
|
103 |
| - breadcrumb |
104 |
| - .setMessage(operation.name) |
105 |
| - .setCategory(operation.type); |
106 |
| - |
107 |
| - // TODO: Maybe move this to a different place? It isn't a breadcrumb |
108 | 36 | if (this.options.setTransaction) {
|
109 |
| - this.setTransaction(operation); |
| 37 | + setTransaction(operation); |
110 | 38 | }
|
111 | 39 |
|
112 |
| - // TODO: Maybe move this to a different place? It isn't a breadcrumb |
113 | 40 | if (this.options.setFingerprint) {
|
114 |
| - this.setFingerprint(); |
115 |
| - } |
116 |
| - |
117 |
| - if (this.options.breadcrumb?.includeQuery) { |
118 |
| - breadcrumb.setQuery(operation.query); |
119 |
| - } |
120 |
| - |
121 |
| - if (this.options.breadcrumb?.includeCache) { |
122 |
| - breadcrumb.setCache(operation.cache); |
| 41 | + setFingerprint(operation); |
123 | 42 | }
|
124 | 43 |
|
125 |
| - if (this.options.breadcrumb?.includeVariables) { |
126 |
| - breadcrumb.setVariables(operation.variables); |
127 |
| - } |
128 |
| - |
129 |
| - if (this.options?.breadcrumb?.includeContextKeys?.length) { |
130 |
| - breadcrumb.setContext(operation.getContextKeys(this.options.breadcrumb.includeContextKeys)); |
131 |
| - } |
132 |
| - }; |
133 |
| - |
134 |
| - /** |
135 |
| - * Handle the operation's response |
136 |
| - * The breadcrumb is not yet attached to Sentry after this method |
137 |
| - * @param {FetchResult} result |
138 |
| - * @param {OperationBreadcrumb} breadcrumb |
139 |
| - * @param observer |
140 |
| - */ |
141 |
| - handleResult = (result: FetchResult, breadcrumb: OperationBreadcrumb, observer: any): void => { |
142 |
| - if (this.options.breadcrumb?.includeResponse) { |
143 |
| - breadcrumb.setResponse(result); |
144 |
| - } |
145 |
| - |
146 |
| - observer.next(result); |
147 |
| - }; |
148 |
| - |
149 |
| - /** |
150 |
| - * Changes the level and type of the breadcrumb to `error` |
151 |
| - * Furthermore, if the includeError option is truthy, the error data will be attached |
152 |
| - * Then, the error will be attached to Sentry |
153 |
| - * @param {OperationBreadcrumb} breadcrumb |
154 |
| - * @param error |
155 |
| - * @param observer |
156 |
| - */ |
157 |
| - handleError = (breadcrumb: OperationBreadcrumb, error: any, observer: any): void => { |
158 |
| - breadcrumb |
159 |
| - .setLevel(Severity.Error) |
160 |
| - .setType('error'); |
161 |
| - |
162 |
| - if (this.options.breadcrumb?.includeError) { |
163 |
| - breadcrumb.setError(error); |
164 |
| - } |
| 44 | + const breadcrumb = this.options.attachBreadcrumbs |
| 45 | + ? makeBreadcrumb(operation, this.options) |
| 46 | + : undefined; |
| 47 | + |
| 48 | + // While this could be done more simplistically by simply subscribing, |
| 49 | + // wrapping the observer in our own observer ensures we get the results |
| 50 | + // before they are passed along to other observers. This guarantees we |
| 51 | + // get to run our instrumentation before others observers potentially |
| 52 | + // throw and thus flush the results to Sentry. |
| 53 | + return new Observable<FetchResult>((originalObserver) => { |
| 54 | + const subscription = forward(operation).subscribe({ |
| 55 | + next: (result) => { |
| 56 | + if (this.options.attachBreadcrumbs) { |
| 57 | + // We must have a breadcrumb if attachBreadcrumbs was set |
| 58 | + (breadcrumb as GraphQLBreadcrumb).level = severityForResult(result); |
| 59 | + |
| 60 | + if (this.options.attachBreadcrumbs.includeFetchResult) { |
| 61 | + // We must have a breadcrumb if attachBreadcrumbs was set |
| 62 | + (breadcrumb as GraphQLBreadcrumb).data.fetchResult = result; |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + originalObserver.next(result); |
| 67 | + }, |
| 68 | + complete: () => { |
| 69 | + if (this.options.attachBreadcrumbs) { |
| 70 | + attachBreadcrumbToSentry( |
| 71 | + operation, |
| 72 | + // We must have a breadcrumb if attachBreadcrumbs was set |
| 73 | + breadcrumb as GraphQLBreadcrumb, |
| 74 | + this.options, |
| 75 | + ); |
| 76 | + } |
| 77 | + |
| 78 | + originalObserver.complete(); |
| 79 | + }, |
| 80 | + error: (error) => { |
| 81 | + if (this.options.attachBreadcrumbs) { |
| 82 | + // We must have a breadcrumb if attachBreadcrumbs was set |
| 83 | + (breadcrumb as GraphQLBreadcrumb).level = Severity.Error; |
| 84 | + |
| 85 | + if (this.options.attachBreadcrumbs.includeError) { |
| 86 | + // We must have a breadcrumb if attachBreadcrumbs was set |
| 87 | + (breadcrumb as GraphQLBreadcrumb).data.error = error; |
| 88 | + } |
| 89 | + |
| 90 | + attachBreadcrumbToSentry( |
| 91 | + operation, |
| 92 | + // We must have a breadcrumb if attachBreadcrumbs was set |
| 93 | + breadcrumb as GraphQLBreadcrumb, |
| 94 | + this.options, |
| 95 | + ); |
| 96 | + } |
| 97 | + |
| 98 | + originalObserver.error(error); |
| 99 | + }, |
| 100 | + }); |
165 | 101 |
|
166 |
| - this.attachBreadcrumbToSentry(breadcrumb); |
167 |
| - |
168 |
| - observer.error(error); |
169 |
| - }; |
170 |
| - |
171 |
| - /** |
172 |
| - * Since no error occurred, it is time to attach the breadcrumb to Sentry |
173 |
| - * @param {OperationBreadcrumb} breadcrumb |
174 |
| - * @param observer |
175 |
| - */ |
176 |
| - handleComplete = (breadcrumb: OperationBreadcrumb, observer: any): void => { |
177 |
| - this.attachBreadcrumbToSentry(breadcrumb); |
178 |
| - observer.complete(); |
179 |
| - }; |
180 |
| - |
181 |
| - /** |
182 |
| - * Set the Sentry transaction |
183 |
| - * @param {Operation} operation |
184 |
| - */ |
185 |
| - setTransaction = (operation: Operation): void => { |
186 |
| - configureScope((scope: Scope) => { |
187 |
| - scope.setTransactionName(operation.name); |
188 |
| - }); |
189 |
| - }; |
190 |
| - |
191 |
| - /** |
192 |
| - * Set the Sentry fingerprint |
193 |
| - */ |
194 |
| - setFingerprint = (): void => { |
195 |
| - configureScope((scope: Scope) => { |
196 |
| - scope.setFingerprint([ |
197 |
| - '{{default}}', |
198 |
| - '{{transaction}}', |
199 |
| - ]); |
| 102 | + return () => { |
| 103 | + subscription.unsubscribe(); |
| 104 | + }; |
200 | 105 | });
|
201 |
| - }; |
202 |
| - |
203 |
| - /** |
204 |
| - * Attach the breadcrumb to the Sentry event |
205 |
| - * @param {OperationBreadcrumb} breadcrumb |
206 |
| - */ |
207 |
| - attachBreadcrumbToSentry = (breadcrumb: OperationBreadcrumb): void => { |
208 |
| - // Apply options |
209 |
| - if (this.options.breadcrumb?.enable === false) return; |
210 |
| - if (breadcrumb.filtered) return; |
211 |
| - |
212 |
| - if (breadcrumb.flushed) { |
213 |
| - console.warn('[apollo-link-sentry] SentryLink.attachBreadcrumbToSentry() was called on an already flushed breadcrumb'); |
214 |
| - return; |
215 |
| - } |
216 |
| - |
217 |
| - if (typeof this.options.beforeBreadcrumb === 'function') { |
218 |
| - const after = this.options.beforeBreadcrumb(breadcrumb); |
219 |
| - addBreadcrumb(after.flush()); |
220 |
| - return; |
221 |
| - } |
| 106 | + } |
| 107 | +} |
222 | 108 |
|
223 |
| - addBreadcrumb(breadcrumb.flush()); |
224 |
| - }; |
| 109 | +function severityForResult(result: FetchResult): Severity { |
| 110 | + return result.errors && result.errors.length > 0 |
| 111 | + ? Severity.Error |
| 112 | + : Severity.Info; |
225 | 113 | }
|
0 commit comments