Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support user context #17

Merged
merged 11 commits into from
May 15, 2018
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": "[email protected]",
/// "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}, '
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, did dartfmt do this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it did. it appears that the travis rule runs dartfmt and fails if there is anything changed after running it. so after I ran dartfmt locally and uploaded it now passes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should file a bug.

'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 protected]",
ipAddress: "127.0.0.1");
final eventUserContext = new User(
id: "event_user",
username: "username",
email: "[email protected]",
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 protected]",
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 protected]',
'ip_address': '127.0.0.1',
'extras': {'foo': 'bar'}
},
},
);
});
Expand Down