Skip to content

Commit

Permalink
Support user context (flutter#17)
Browse files Browse the repository at this point in the history
Add support for user context information in events submitted to Sentry.
  • Loading branch information
dustin-graham authored and yjbanov committed May 15, 2018
1 parent 8650283 commit 78ef3f7
Show file tree
Hide file tree
Showing 2 changed files with 158 additions and 1 deletion.
75 changes: 75 additions & 0 deletions lib/sentry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ class SentryClient {
/// Attached to the event payload.
final String projectId;

/// The user data that will get sent with every logged event
///
/// Note that a [Event.userContext] that is set on a logged [Event]
/// will override the [User] context set here.
///
/// see: https://docs.sentry.io/learn/context/#capturing-the-user
User userContext;

@visibleForTesting
String get postUri =>
'${dsnUri.scheme}://${dsnUri.host}/api/$projectId/store/';
Expand Down Expand Up @@ -170,6 +178,10 @@ class SentryClient {
if (environmentAttributes != null)
mergeAttributes(environmentAttributes.toJson(), into: data);

// merge the user context
if (userContext != null) {
mergeAttributes({'user': userContext.toJson()}, into: data);
}
mergeAttributes(event.toJson(), into: data);

List<int> body = utf8.encode(json.encode(data));
Expand Down Expand Up @@ -285,6 +297,7 @@ class Event {
this.tags,
this.extra,
this.fingerprint,
this.userContext,
});

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

/// User information that is sent with the logged [Event]
///
/// The value in this field overrides the user context
/// set in [SentryClient.userContext] for this logged event.
final User userContext;

/// Used to deduplicate events by grouping ones with the same fingerprint
/// together.
///
Expand Down Expand Up @@ -389,9 +408,65 @@ class Event {

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

Map<String, dynamic> userContextMap;
if (userContext != null &&
(userContextMap = userContext.toJson()).isNotEmpty)
json['user'] = userContextMap;

if (fingerprint != null && fingerprint.isNotEmpty)
json['fingerprint'] = fingerprint;

return json;
}
}

/// An interface which describes the authenticated User for a request.
/// You should provide at least either an id (a unique identifier for an
/// authenticated user) or ip_address (their IP address).
///
/// Conforms to the User Interface contract for Sentry
/// https://docs.sentry.io/clientdev/interfaces/user/
///
/// The outgoing json representation is:
/// ```
/// "user": {
/// "id": "unique_id",
/// "username": "my_user",
/// "email": "foo@example.com",
/// "ip_address": "127.0.0.1",
/// "subscription": "basic"
/// }
/// ```
class User {
/// The unique ID of the user.
final String id;

/// The username of the user
final String username;

/// The email address of the user.
final String email;

/// The IP of the user.
final String ipAddress;

/// Any other user context information that may be helpful
/// All other keys are stored as extra information but not
/// specifically processed by sentry.
final Map<String, dynamic> extras;

/// At a minimum you must set an [id] or an [ipAddress]
const User({this.id, this.username, this.email, this.ipAddress, this.extras})
: assert(id != null || ipAddress != null);

/// produces a [Map] that can be serialized to JSON
Map<String, dynamic> toJson() {
return {
"id": id,
"username": username,
"email": email,
"ip_address": ipAddress,
"extras": extras,
};
}
}
84 changes: 83 additions & 1 deletion test/sentry_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ void main() {
'Content-Type': 'application/json',
'X-Sentry-Auth': 'Sentry sentry_version=6, '
'sentry_client=${SentryClient.sentryClient}, '
'sentry_timestamp=${fakeClock.now().millisecondsSinceEpoch}, '
'sentry_timestamp=${fakeClock
.now()
.millisecondsSinceEpoch}, '
'sentry_key=public, '
'sentry_secret=secret',
};
Expand Down Expand Up @@ -171,10 +173,82 @@ void main() {

await client.close();
});

test('$Event userContext overrides client', () async {
final MockClient httpMock = new MockClient();
final Clock fakeClock = new Clock.fixed(new DateTime(2017, 1, 2));

String loggedUserId; // used to find out what user context was sent
httpMock.answerWith((Invocation invocation) async {
if (invocation.memberName == #close) {
return null;
}
if (invocation.memberName == #post) {
// parse the body and detect which user context was sent
var bodyData = invocation.namedArguments[new Symbol("body")];
var decoded = new Utf8Codec().decode(bodyData);
var decodedJson = new JsonDecoder().convert(decoded);
loggedUserId = decodedJson['user']['id'];
return new Response('', 401, headers: <String, String>{
'x-sentry-error': 'Invalid api key',
});
}
fail('Unexpected invocation of ${invocation.memberName} in HttpMock');
});

final clientUserContext = new User(
id: "client_user",
username: "username",
email: "email@email.com",
ipAddress: "127.0.0.1");
final eventUserContext = new User(
id: "event_user",
username: "username",
email: "email@email.com",
ipAddress: "127.0.0.1",
extras: {"foo": "bar"});

final SentryClient client = new SentryClient(
dsn: _testDsn,
httpClient: httpMock,
clock: fakeClock,
uuidGenerator: () => 'X' * 32,
compressPayload: false,
environmentAttributes: const Event(
serverName: 'test.server.com',
release: '1.2.3',
environment: 'staging',
),
);
client.userContext = clientUserContext;

try {
throw new ArgumentError('Test error');
} catch (error, stackTrace) {
final eventWithoutContext =
new Event(exception: error, stackTrace: stackTrace);
final eventWithContext = new Event(
exception: error,
stackTrace: stackTrace,
userContext: eventUserContext);
await client.capture(event: eventWithoutContext);
expect(loggedUserId, clientUserContext.id);
await client.capture(event: eventWithContext);
expect(loggedUserId, eventUserContext.id);
}

await client.close();
});
});

group('$Event', () {
test('serializes to JSON', () {
final user = new User(
id: "user_id",
username: "username",
email: "email@email.com",
ipAddress: "127.0.0.1",
extras: {"foo": "bar"});
expect(
new Event(
message: 'test-message',
Expand All @@ -190,6 +264,7 @@ void main() {
'g': 2,
},
fingerprint: <String>[Event.defaultFingerprint, 'foo'],
userContext: user,
).toJson(),
<String, dynamic>{
'platform': 'dart',
Expand All @@ -203,6 +278,13 @@ void main() {
'tags': {'a': 'b', 'c': 'd'},
'extra': {'e': 'f', 'g': 2},
'fingerprint': ['{{ default }}', 'foo'],
'user': {
'id': 'user_id',
'username': 'username',
'email': 'email@email.com',
'ip_address': '127.0.0.1',
'extras': {'foo': 'bar'}
},
},
);
});
Expand Down

0 comments on commit 78ef3f7

Please sign in to comment.