Skip to content

Commit 78ef3f7

Browse files
dustin-grahamyjbanov
authored andcommitted
Support user context (flutter#17)
Add support for user context information in events submitted to Sentry.
1 parent 8650283 commit 78ef3f7

File tree

2 files changed

+158
-1
lines changed

2 files changed

+158
-1
lines changed

lib/sentry.dart

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ class SentryClient {
143143
/// Attached to the event payload.
144144
final String projectId;
145145

146+
/// The user data that will get sent with every logged event
147+
///
148+
/// Note that a [Event.userContext] that is set on a logged [Event]
149+
/// will override the [User] context set here.
150+
///
151+
/// see: https://docs.sentry.io/learn/context/#capturing-the-user
152+
User userContext;
153+
146154
@visibleForTesting
147155
String get postUri =>
148156
'${dsnUri.scheme}://${dsnUri.host}/api/$projectId/store/';
@@ -170,6 +178,10 @@ class SentryClient {
170178
if (environmentAttributes != null)
171179
mergeAttributes(environmentAttributes.toJson(), into: data);
172180

181+
// merge the user context
182+
if (userContext != null) {
183+
mergeAttributes({'user': userContext.toJson()}, into: data);
184+
}
173185
mergeAttributes(event.toJson(), into: data);
174186

175187
List<int> body = utf8.encode(json.encode(data));
@@ -285,6 +297,7 @@ class Event {
285297
this.tags,
286298
this.extra,
287299
this.fingerprint,
300+
this.userContext,
288301
});
289302

290303
/// The logger that logged the event.
@@ -330,6 +343,12 @@ class Event {
330343
/// they must be JSON-serializable.
331344
final Map<String, dynamic> extra;
332345

346+
/// User information that is sent with the logged [Event]
347+
///
348+
/// The value in this field overrides the user context
349+
/// set in [SentryClient.userContext] for this logged event.
350+
final User userContext;
351+
333352
/// Used to deduplicate events by grouping ones with the same fingerprint
334353
/// together.
335354
///
@@ -389,9 +408,65 @@ class Event {
389408

390409
if (extra != null && extra.isNotEmpty) json['extra'] = extra;
391410

411+
Map<String, dynamic> userContextMap;
412+
if (userContext != null &&
413+
(userContextMap = userContext.toJson()).isNotEmpty)
414+
json['user'] = userContextMap;
415+
392416
if (fingerprint != null && fingerprint.isNotEmpty)
393417
json['fingerprint'] = fingerprint;
394418

395419
return json;
396420
}
397421
}
422+
423+
/// An interface which describes the authenticated User for a request.
424+
/// You should provide at least either an id (a unique identifier for an
425+
/// authenticated user) or ip_address (their IP address).
426+
///
427+
/// Conforms to the User Interface contract for Sentry
428+
/// https://docs.sentry.io/clientdev/interfaces/user/
429+
///
430+
/// The outgoing json representation is:
431+
/// ```
432+
/// "user": {
433+
/// "id": "unique_id",
434+
/// "username": "my_user",
435+
/// "email": "[email protected]",
436+
/// "ip_address": "127.0.0.1",
437+
/// "subscription": "basic"
438+
/// }
439+
/// ```
440+
class User {
441+
/// The unique ID of the user.
442+
final String id;
443+
444+
/// The username of the user
445+
final String username;
446+
447+
/// The email address of the user.
448+
final String email;
449+
450+
/// The IP of the user.
451+
final String ipAddress;
452+
453+
/// Any other user context information that may be helpful
454+
/// All other keys are stored as extra information but not
455+
/// specifically processed by sentry.
456+
final Map<String, dynamic> extras;
457+
458+
/// At a minimum you must set an [id] or an [ipAddress]
459+
const User({this.id, this.username, this.email, this.ipAddress, this.extras})
460+
: assert(id != null || ipAddress != null);
461+
462+
/// produces a [Map] that can be serialized to JSON
463+
Map<String, dynamic> toJson() {
464+
return {
465+
"id": id,
466+
"username": username,
467+
"email": email,
468+
"ip_address": ipAddress,
469+
"extras": extras,
470+
};
471+
}
472+
}

test/sentry_test.dart

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ void main() {
7474
'Content-Type': 'application/json',
7575
'X-Sentry-Auth': 'Sentry sentry_version=6, '
7676
'sentry_client=${SentryClient.sentryClient}, '
77-
'sentry_timestamp=${fakeClock.now().millisecondsSinceEpoch}, '
77+
'sentry_timestamp=${fakeClock
78+
.now()
79+
.millisecondsSinceEpoch}, '
7880
'sentry_key=public, '
7981
'sentry_secret=secret',
8082
};
@@ -171,10 +173,82 @@ void main() {
171173

172174
await client.close();
173175
});
176+
177+
test('$Event userContext overrides client', () async {
178+
final MockClient httpMock = new MockClient();
179+
final Clock fakeClock = new Clock.fixed(new DateTime(2017, 1, 2));
180+
181+
String loggedUserId; // used to find out what user context was sent
182+
httpMock.answerWith((Invocation invocation) async {
183+
if (invocation.memberName == #close) {
184+
return null;
185+
}
186+
if (invocation.memberName == #post) {
187+
// parse the body and detect which user context was sent
188+
var bodyData = invocation.namedArguments[new Symbol("body")];
189+
var decoded = new Utf8Codec().decode(bodyData);
190+
var decodedJson = new JsonDecoder().convert(decoded);
191+
loggedUserId = decodedJson['user']['id'];
192+
return new Response('', 401, headers: <String, String>{
193+
'x-sentry-error': 'Invalid api key',
194+
});
195+
}
196+
fail('Unexpected invocation of ${invocation.memberName} in HttpMock');
197+
});
198+
199+
final clientUserContext = new User(
200+
id: "client_user",
201+
username: "username",
202+
203+
ipAddress: "127.0.0.1");
204+
final eventUserContext = new User(
205+
id: "event_user",
206+
username: "username",
207+
208+
ipAddress: "127.0.0.1",
209+
extras: {"foo": "bar"});
210+
211+
final SentryClient client = new SentryClient(
212+
dsn: _testDsn,
213+
httpClient: httpMock,
214+
clock: fakeClock,
215+
uuidGenerator: () => 'X' * 32,
216+
compressPayload: false,
217+
environmentAttributes: const Event(
218+
serverName: 'test.server.com',
219+
release: '1.2.3',
220+
environment: 'staging',
221+
),
222+
);
223+
client.userContext = clientUserContext;
224+
225+
try {
226+
throw new ArgumentError('Test error');
227+
} catch (error, stackTrace) {
228+
final eventWithoutContext =
229+
new Event(exception: error, stackTrace: stackTrace);
230+
final eventWithContext = new Event(
231+
exception: error,
232+
stackTrace: stackTrace,
233+
userContext: eventUserContext);
234+
await client.capture(event: eventWithoutContext);
235+
expect(loggedUserId, clientUserContext.id);
236+
await client.capture(event: eventWithContext);
237+
expect(loggedUserId, eventUserContext.id);
238+
}
239+
240+
await client.close();
241+
});
174242
});
175243

176244
group('$Event', () {
177245
test('serializes to JSON', () {
246+
final user = new User(
247+
id: "user_id",
248+
username: "username",
249+
250+
ipAddress: "127.0.0.1",
251+
extras: {"foo": "bar"});
178252
expect(
179253
new Event(
180254
message: 'test-message',
@@ -190,6 +264,7 @@ void main() {
190264
'g': 2,
191265
},
192266
fingerprint: <String>[Event.defaultFingerprint, 'foo'],
267+
userContext: user,
193268
).toJson(),
194269
<String, dynamic>{
195270
'platform': 'dart',
@@ -203,6 +278,13 @@ void main() {
203278
'tags': {'a': 'b', 'c': 'd'},
204279
'extra': {'e': 'f', 'g': 2},
205280
'fingerprint': ['{{ default }}', 'foo'],
281+
'user': {
282+
'id': 'user_id',
283+
'username': 'username',
284+
'email': '[email protected]',
285+
'ip_address': '127.0.0.1',
286+
'extras': {'foo': 'bar'}
287+
},
206288
},
207289
);
208290
});

0 commit comments

Comments
 (0)