Skip to content

Commit f9e674d

Browse files
authored
Feat: SentryHttpClient tracks request durations (#414)
* Feat: SentryHttpClient tracks request durations * Remove comment * Add PR ID * add test fixture & use finally block * Further improve tests * Use Test fixture * Remove not needed comments
1 parent c1784cd commit f9e674d

File tree

3 files changed

+158
-128
lines changed

3 files changed

+158
-128
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Fix: `Sentry.close()` closes native SDK integrations (#388)
44
* Fix: Mark `Sentry.currentHub` as deprecated (#406)
55
* Fix: Use name from pubspec.yaml for release if package id is not available (#411)
6+
* Feat: `SentryHttpClient` tracks the duration which a request takes and logs failed requests (#414)
67

78
# 5.0.0
89

dart/lib/src/http_client/sentry_http_client.dart

+34-16
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import 'package:http/http.dart';
2-
3-
import '../../sentry.dart';
2+
import '../protocol/sentry_level.dart';
3+
import '../protocol/breadcrumb.dart';
4+
import '../hub.dart';
5+
import '../hub_adapter.dart';
46

57
/// A [http](https://pub.dev/packages/http)-package compatible HTTP client
68
/// which records requests as breadcrumbs.
@@ -40,11 +42,6 @@ import '../../sentry.dart';
4042
/// client.close();
4143
/// }
4244
/// ```
43-
//
44-
// Possible enhancement:
45-
// Track the time the HTTP request took.
46-
// For example with Darts Stopwatch:
47-
// https://api.dart.dev/stable/2.10.4/dart-core/Stopwatch-class.html
4845
class SentryHttpClient extends BaseClient {
4946
SentryHttpClient({Client? client, Hub? hub})
5047
: _hub = hub ?? HubAdapter(),
@@ -55,22 +52,43 @@ class SentryHttpClient extends BaseClient {
5552

5653
@override
5754
Future<StreamedResponse> send(BaseRequest request) async {
58-
final response = await _client.send(request);
5955
// See https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/
60-
_hub.addBreadcrumb(
61-
Breadcrumb(
56+
57+
var requestHadException = false;
58+
int? statusCode;
59+
String? reason;
60+
61+
final stopwatch = Stopwatch();
62+
stopwatch.start();
63+
64+
try {
65+
final response = await _client.send(request);
66+
67+
statusCode = response.statusCode;
68+
reason = response.reasonPhrase;
69+
70+
return response;
71+
} catch (_) {
72+
requestHadException = true;
73+
rethrow;
74+
} finally {
75+
stopwatch.stop();
76+
77+
var breadcrumb = Breadcrumb(
78+
level: requestHadException ? SentryLevel.error : SentryLevel.info,
6279
type: 'http',
6380
category: 'http',
6481
data: {
6582
'url': request.url.toString(),
6683
'method': request.method,
67-
'status_code': response.statusCode,
68-
// reason is optional, therefor only add it in case it is not null
69-
if (response.reasonPhrase != null) 'reason': response.reasonPhrase,
84+
if (statusCode != null) 'status_code': statusCode,
85+
if (reason != null) 'reason': reason,
86+
'duration': stopwatch.elapsed.toString(),
7087
},
71-
),
72-
);
73-
return response;
88+
);
89+
90+
_hub.addBreadcrumb(breadcrumb);
91+
}
7492
}
7593

7694
@override

dart/test/http_client/sentry_http_client_test.dart

+123-112
Original file line numberDiff line numberDiff line change
@@ -9,182 +9,159 @@ import 'package:test/test.dart';
99

1010
import '../mocks/mock_hub.dart';
1111

12+
final requestUri = Uri.parse('https://example.com');
13+
1214
void main() {
1315
group(SentryHttpClient, () {
14-
test('GET: happy path', () async {
15-
final mockHub = MockHub();
16+
late var fixture;
1617

17-
final mockClient = MockClient((request) async {
18-
expect(request.url, Uri.parse('https://example.com'));
19-
return Response('', 200, reasonPhrase: 'OK');
20-
});
18+
setUp(() {
19+
fixture = Fixture();
20+
});
2121

22-
final client = SentryHttpClient(client: mockClient, hub: mockHub);
22+
test('GET: happy path', () async {
23+
final sut =
24+
fixture.getSut(fixture.getClient(statusCode: 200, reason: 'OK'));
2325

24-
final response = await client.get(Uri.parse('https://example.com'));
26+
final response = await sut.get(requestUri);
2527
expect(response.statusCode, 200);
2628

27-
expect(mockHub.addBreadcrumbCalls.length, 1);
28-
final breadcrumb = mockHub.addBreadcrumbCalls.first.crumb;
29+
expect(fixture.hub.addBreadcrumbCalls.length, 1);
30+
final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb;
2931

3032
expect(breadcrumb.type, 'http');
31-
expect(breadcrumb.data, <String, dynamic>{
32-
'url': 'https://example.com',
33-
'method': 'GET',
34-
'status_code': 200,
35-
'reason': 'OK',
36-
});
33+
expect(breadcrumb.data?['url'], 'https://example.com');
34+
expect(breadcrumb.data?['method'], 'GET');
35+
expect(breadcrumb.data?['status_code'], 200);
36+
expect(breadcrumb.data?['reason'], 'OK');
37+
expect(breadcrumb.data?['duration'], isNotNull);
3738
});
3839

3940
test('GET: happy path for 404', () async {
40-
final mockHub = MockHub();
41+
final sut = fixture
42+
.getSut(fixture.getClient(statusCode: 404, reason: 'NOT FOUND'));
4143

42-
final mockClient = MockClient((request) async {
43-
expect(request.url, Uri.parse('https://example.com'));
44-
return Response('', 404, reasonPhrase: 'NOT FOUND');
45-
});
44+
final response = await sut.get(requestUri);
4645

47-
final client = SentryHttpClient(client: mockClient, hub: mockHub);
48-
49-
final response = await client.get(Uri.parse('https://example.com'));
5046
expect(response.statusCode, 404);
5147

52-
expect(mockHub.addBreadcrumbCalls.length, 1);
53-
final breadcrumb = mockHub.addBreadcrumbCalls.first.crumb;
48+
expect(fixture.hub.addBreadcrumbCalls.length, 1);
49+
final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb;
5450

5551
expect(breadcrumb.type, 'http');
56-
expect(breadcrumb.data, <String, dynamic>{
57-
'url': 'https://example.com',
58-
'method': 'GET',
59-
'status_code': 404,
60-
'reason': 'NOT FOUND',
61-
});
52+
expect(breadcrumb.data?['url'], 'https://example.com');
53+
expect(breadcrumb.data?['method'], 'GET');
54+
expect(breadcrumb.data?['status_code'], 404);
55+
expect(breadcrumb.data?['reason'], 'NOT FOUND');
56+
expect(breadcrumb.data?['duration'], isNotNull);
6257
});
6358

6459
test('POST: happy path', () async {
65-
final mockHub = MockHub();
66-
67-
final mockClient = MockClient((request) async {
68-
expect(request.url, Uri.parse('https://example.com'));
69-
return Response('', 200);
70-
});
60+
final sut = fixture.getSut(fixture.getClient(statusCode: 200));
7161

72-
final client = SentryHttpClient(client: mockClient, hub: mockHub);
73-
74-
final response = await client.post(Uri.parse('https://example.com'));
62+
final response = await sut.post(requestUri);
7563
expect(response.statusCode, 200);
7664

77-
expect(mockHub.addBreadcrumbCalls.length, 1);
78-
final breadcrumb = mockHub.addBreadcrumbCalls.first.crumb;
65+
expect(fixture.hub.addBreadcrumbCalls.length, 1);
66+
final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb;
7967

8068
expect(breadcrumb.type, 'http');
81-
expect(breadcrumb.data, <String, dynamic>{
82-
'url': 'https://example.com',
83-
'method': 'POST',
84-
'status_code': 200,
85-
});
69+
expect(breadcrumb.data?['url'], 'https://example.com');
70+
expect(breadcrumb.data?['method'], 'POST');
71+
expect(breadcrumb.data?['status_code'], 200);
72+
expect(breadcrumb.data?['duration'], isNotNull);
8673
});
8774

8875
test('PUT: happy path', () async {
89-
final mockHub = MockHub();
76+
final sut = fixture.getSut(fixture.getClient(statusCode: 200));
9077

91-
final mockClient = MockClient((request) async {
92-
expect(request.url, Uri.parse('https://example.com'));
93-
return Response('', 200);
94-
});
95-
96-
final client = SentryHttpClient(client: mockClient, hub: mockHub);
97-
98-
final response = await client.put(Uri.parse('https://example.com'));
78+
final response = await sut.put(requestUri);
9979
expect(response.statusCode, 200);
10080

101-
expect(mockHub.addBreadcrumbCalls.length, 1);
102-
final breadcrumb = mockHub.addBreadcrumbCalls.first.crumb;
81+
expect(fixture.hub.addBreadcrumbCalls.length, 1);
82+
final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb;
10383

10484
expect(breadcrumb.type, 'http');
105-
expect(breadcrumb.data, <String, dynamic>{
106-
'url': 'https://example.com',
107-
'method': 'PUT',
108-
'status_code': 200,
109-
});
85+
expect(breadcrumb.data?['url'], 'https://example.com');
86+
expect(breadcrumb.data?['method'], 'PUT');
87+
expect(breadcrumb.data?['status_code'], 200);
88+
expect(breadcrumb.data?['duration'], isNotNull);
11089
});
11190

11291
test('DELETE: happy path', () async {
113-
final mockHub = MockHub();
114-
115-
final mockClient = MockClient((request) async {
116-
expect(request.url, Uri.parse('https://example.com'));
117-
return Response('', 200, reasonPhrase: 'OK');
118-
});
119-
120-
final client = SentryHttpClient(client: mockClient, hub: mockHub);
92+
final sut = fixture.getSut(fixture.getClient(statusCode: 200));
12193

122-
final response = await client.delete(Uri.parse('https://example.com'));
94+
final response = await sut.delete(requestUri);
12395
expect(response.statusCode, 200);
12496

125-
expect(mockHub.addBreadcrumbCalls.length, 1);
126-
final breadcrumb = mockHub.addBreadcrumbCalls.first.crumb;
97+
expect(fixture.hub.addBreadcrumbCalls.length, 1);
98+
final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb;
12799

128100
expect(breadcrumb.type, 'http');
129-
expect(breadcrumb.data, <String, dynamic>{
130-
'url': 'https://example.com',
131-
'method': 'DELETE',
132-
'status_code': 200,
133-
'reason': 'OK',
134-
});
101+
expect(breadcrumb.data?['url'], 'https://example.com');
102+
expect(breadcrumb.data?['method'], 'DELETE');
103+
expect(breadcrumb.data?['status_code'], 200);
104+
expect(breadcrumb.data?['duration'], isNotNull);
135105
});
136106

137107
/// Tests, that in case an exception gets thrown, that
138-
/// - no breadcrumb gets added
139-
/// - no exception gets reported by Sentry, in case the user wants to
140-
/// handle the exception
141-
test('no breadcrumb for ClientException', () async {
142-
final url = Uri.parse('https://example.com');
143-
144-
final mockHub = MockHub();
145-
146-
final mockClient = MockClient((request) async {
147-
expect(request.url, url);
148-
throw ClientException('test', url);
149-
});
150-
151-
final client = SentryHttpClient(client: mockClient, hub: mockHub);
108+
/// no exception gets reported by Sentry, in case the user wants to
109+
/// handle the exception
110+
test('no captureException for ClientException', () async {
111+
final sut = fixture.getSut(MockClient((request) async {
112+
expect(request.url, requestUri);
113+
throw ClientException('test', requestUri);
114+
}));
152115

153116
try {
154-
await client.get(Uri.parse('https://example.com'));
117+
await sut.get(requestUri);
155118
fail('Method did not throw');
156119
} on ClientException catch (e) {
157120
expect(e.message, 'test');
158-
expect(e.uri, url);
121+
expect(e.uri, requestUri);
159122
}
160123

161-
expect(mockHub.addBreadcrumbCalls.length, 0);
162-
expect(mockHub.captureExceptionCalls.length, 0);
124+
expect(fixture.hub.captureExceptionCalls.length, 0);
163125
});
164126

165127
/// SocketException are only a thing on dart:io platforms.
166128
/// otherwise this is equal to the test above
167-
test('no breadcrumb for SocketException', () async {
168-
final url = Uri.parse('https://example.com');
169-
170-
final mockHub = MockHub();
171-
172-
final mockClient = MockClient((request) async {
173-
expect(request.url, url);
129+
test('no captureException for SocketException', () async {
130+
final sut = fixture.getSut(MockClient((request) async {
131+
expect(request.url, requestUri);
174132
throw SocketException('test');
175-
});
176-
177-
final client = SentryHttpClient(client: mockClient, hub: mockHub);
133+
}));
178134

179135
try {
180-
await client.get(Uri.parse('https://example.com'));
136+
await sut.get(requestUri);
181137
fail('Method did not throw');
182138
} on SocketException catch (e) {
183139
expect(e.message, 'test');
184140
}
185141

186-
expect(mockHub.addBreadcrumbCalls.length, 0);
187-
expect(mockHub.captureExceptionCalls.length, 0);
142+
expect(fixture.hub.captureExceptionCalls.length, 0);
143+
});
144+
145+
test('breadcrumb gets added when an exception gets thrown', () async {
146+
final sut = fixture.getSut(MockClient((request) async {
147+
expect(request.url, requestUri);
148+
throw Exception('foo bar');
149+
}));
150+
151+
try {
152+
await sut.get(requestUri);
153+
fail('Method did not throw');
154+
} on Exception catch (_) {}
155+
156+
expect(fixture.hub.addBreadcrumbCalls.length, 1);
157+
158+
final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb;
159+
160+
expect(breadcrumb.type, 'http');
161+
expect(breadcrumb.data?['url'], 'https://example.com');
162+
expect(breadcrumb.data?['method'], 'GET');
163+
expect(breadcrumb.level, SentryLevel.error);
164+
expect(breadcrumb.data?['duration'], isNotNull);
188165
});
189166

190167
test('close does get called for user defined client', () async {
@@ -199,7 +176,41 @@ void main() {
199176
expect(mockHub.captureExceptionCalls.length, 0);
200177
verify(mockClient.close());
201178
});
179+
180+
test('Breadcrumb has correct duration', () async {
181+
final sut = fixture.getSut(MockClient((request) async {
182+
expect(request.url, requestUri);
183+
await Future.delayed(Duration(seconds: 1));
184+
return Response('', 200, reasonPhrase: 'OK');
185+
}));
186+
187+
final response = await sut.get(requestUri);
188+
expect(response.statusCode, 200);
189+
190+
expect(fixture.hub.addBreadcrumbCalls.length, 1);
191+
final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb;
192+
193+
var durationString = breadcrumb.data!['duration']! as String;
194+
// we don't check for anything below a second
195+
expect(durationString.startsWith('0:00:01'), true);
196+
});
202197
});
203198
}
204199

205200
class CloseableMockClient extends Mock implements BaseClient {}
201+
202+
class Fixture {
203+
SentryHttpClient getSut([MockClient? client]) {
204+
final mc = client ?? getClient();
205+
return SentryHttpClient(client: mc, hub: hub);
206+
}
207+
208+
late MockHub hub = MockHub();
209+
210+
MockClient getClient({int statusCode = 200, String? reason}) {
211+
return MockClient((request) async {
212+
expect(request.url, requestUri);
213+
return Response('', statusCode, reasonPhrase: reason);
214+
});
215+
}
216+
}

0 commit comments

Comments
 (0)