diff --git a/CHANGELOG.md b/CHANGELOG.md index 18603ca05b..89a0c2e95a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Transfer ownership of `sentry_link` to Sentry. You can view the changelog for the previous versions [here](https://github.com/getsentry/sentry-dart/blob/main/sentry_link/CHANGELOG_OLD.md) ([#2338](https://github.com/getsentry/sentry-dart/pull/2338)) + - No functional changes have been made. This version is identical to the previous one. + - Change license from Apache to MIT + ### Deprecations - Manual TTID ([#2477](https://github.com/getsentry/sentry-dart/pull/2477)) @@ -2757,4 +2761,4 @@ Until then, the stable SDK offered by Sentry is at version [3.0.1](https://githu ## 0.0.1 -- basic ability to send exception reports to Sentry.io +- basic ability to send exception reports to Sentry.io \ No newline at end of file diff --git a/link/.gitignore b/link/.gitignore new file mode 100644 index 0000000000..65c34dc86e --- /dev/null +++ b/link/.gitignore @@ -0,0 +1,10 @@ +# Files and directories created by pub. +.dart_tool/ +.packages + +# Conventional directory for build outputs. +build/ + +# Omit committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/link/CHANGELOG.md b/link/CHANGELOG.md new file mode 120000 index 0000000000..04c99a55ca --- /dev/null +++ b/link/CHANGELOG.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/link/CHANGELOG_OLD.md b/link/CHANGELOG_OLD.md new file mode 100644 index 0000000000..98ca92eff9 --- /dev/null +++ b/link/CHANGELOG_OLD.md @@ -0,0 +1,66 @@ +## 0.5.2 + +- Metadata updates + +## 0.5.1 + +- Fix various pub score issues + +## 0.5.0 + +- Require `gql_exec: ">=0.4.4 <2.0.0"` +- Remove newly unused code +- Export a couple extension methods to help converting requests and responses to their Sentry equivalents + +## 0.4.0 + +- Require Dart 3 +- Update to Sentry v8.0.0 + +## 0.3.0 + +- Proper support for GraphQL in Sentry. Sentry added proper support for GraphQL errors in with [#33723](https://github.com/getsentry/sentry/issues/33723) and this library now sends it as per spec. + +## 0.2.1 + +- fix readme + +## 0.2.0 + +This version contains breaking changes + +- Require Sentry v7 +- Instead of multiple `Link`s, there's now just a single one. See the readme for usage instructions +- Add exception extractors for unwrapping of nested `LinkException` +- Add a filter to remove duplicated http breadcrumbs. See readme for usage instructions + +## 0.1.3 + +- Added filter for http breadcrumbs. + +## 0.1.2 + +- Add ability to add breadcrumbs for GraphQL operations + +## 0.1.2 + +- Update `gql` dependencies +- Add inner exceptions for event processor + +## 0.1.0 + +- Add `SentryTracingLink` which creates performance traces +- Add `SentryResponseParser`, `SentryRequestSerializer` and `sentryResponseDecoder` which create spans for (de)serialization operations + +## 0.0.3 + +- Fix an invalid usage of Sentry's context +- Add event processor for nested LinkExceptions + +## 0.0.2 + +- Update dependencies and add some docs + +## 0.0.1 + +- Initial version. diff --git a/link/LICENSE b/link/LICENSE new file mode 100644 index 0000000000..d7c02a5b80 --- /dev/null +++ b/link/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Jonas Uekötter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/link/README.md b/link/README.md new file mode 100644 index 0000000000..61e3e55768 --- /dev/null +++ b/link/README.md @@ -0,0 +1,251 @@ +# Sentry Link (GraphQL) + +[![pub package](https://img.shields.io/pub/v/sentry_link.svg)](https://pub.dev/packages/sentry_link) [![likes](https://img.shields.io/pub/likes/sentry_link)](https://pub.dev/packages/sentry_link/score) [![popularity](https://img.shields.io/pub/popularity/sentry_link)](https://pub.dev/packages/sentry_link/score) [![pub points](https://img.shields.io/pub/points/sentry_link)](https://pub.dev/packages/sentry_link/score) + +## Compatibility list + +This integration is compatible with the following packages. It's also compatible with other packages which are build on [`gql`](https://pub.dev/publishers/gql-dart.dev/packages) suite of packages. + +| package | stats | +|---------|-------| +| [`gql_link`](https://pub.dev/packages/gql_link) | likes popularity pub points | +| [`graphql`](https://pub.dev/packages/graphql) | likes popularity pub points | +| [`ferry`](https://pub.dev/packages/ferry) | likes popularity pub points | +| [`artemis`](https://pub.dev/packages/artemis) | likes popularity pub points | + +## Usage + +Just add `SentryGql.link()` to your links. +It will add error reporting and performance monitoring to your GraphQL operations. + +```dart +final link = Link.from([ + AuthLink(getToken: () async => 'Bearer $personalAccessToken'), + // SentryLink records exceptions + SentryGql.link( + shouldStartTransaction: true, + graphQlErrorsMarkTransactionAsFailed: true, + ), + HttpLink('https://api.github.com/graphql'), +]); +``` + +A GraphQL errors will be reported as seen in the example below: + +Given the following query with an error + +```graphql +query LoadPosts($id: ID!) { + post(id: $id) { + id + # This word is intentionally misspelled to trigger a GraphQL error + titl + body + } +} +``` + +it will be represented in Sentry as seen in the image + + + +## Improve exception reports for `LinkException`s + +`LinkException`s and it subclasses can be arbitrary deeply nested. By adding an exception extractor for it, Sentry can create significantly improved exception reports. + +```dart +Sentry.init((options) { + options.addGqlExtractors(); +}); +``` + +## Performance traces for serialization and parsing + +The [`SentryResponseParser`](https://pub.dev/documentation/sentry_link/latest/sentry_link/SentryResponseParser-class.html) and [`SentryRequestSerializer`](https://pub.dev/documentation/sentry_link/latest/sentry_link/SentryRequestSerializer-class.html) classes can be used to trace the de/serialization process. +Both classes work with the [`HttpLink`](https://pub.dev/packages/gql_http_link) and the [`DioLink`](https://pub.dev/packages/gql_dio_link). +When using the `HttpLink`, you can additionally use the `sentryResponseDecoder` function as explained further down below. + +### Example for `HttpLink` + +This example uses the [`http`](https://docs.sentry.io/platforms/dart/configuration/integrations/http-integration/#performance-monitoring-for-http-requests) integration in addition to this gql integration. + +```dart +import 'package:sentry/sentry.dart'; +import 'package:sentry_link/sentry_link.dart'; + +final link = Link.from([ + AuthLink(getToken: () async => 'Bearer $personalAccessToken'), + SentryGql.link( + shouldStartTransaction: true, + graphQlErrorsMarkTransactionAsFailed: true, + ), + HttpLink( + 'https://api.github.com/graphql', + httpClient: SentryHttpClient(), + serializer: SentryRequestSerializer(), + parser: SentryResponseParser(), + ), + ]); +``` + +### Example for `DioLink` + +This example uses the [`sentry_dio`](https://pub.dev/packages/sentry_dio) integration in addition to this gql integration. + +```dart +import 'package:sentry_link/sentry_link.dart'; +import 'package:sentry_dio/sentry_dio.dart'; + +final link = Link.from([ + AuthLink(getToken: () async => 'Bearer $personalAccessToken'), + SentryGql.link( + shouldStartTransaction: true, + graphQlErrorsMarkTransactionAsFailed: true, + ), + DioLink( + 'https://api.github.com/graphql', + client: Dio()..addSentry(), + serializer: SentryRequestSerializer(), + parser: SentryResponseParser(), + ), + ]); +``` + +
+ HttpLink + +## Bonus `HttpLink` tracing + +```dart +import 'dart:async'; +import 'dart:convert'; + +import 'package:sentry/sentry.dart'; +import 'package:http/http.dart' as http; + +import 'package:sentry_link/sentry_link.dart'; + +final link = Link.from([ + AuthLink(getToken: () async => 'Bearer $personalAccessToken'), + SentryGql.link( + shouldStartTransaction: true, + graphQlErrorsMarkTransactionAsFailed: true, + ), + HttpLink( + 'https://api.github.com/graphql', + httpClient: SentryHttpClient(networkTracing: true), + serializer: SentryRequestSerializer(), + parser: SentryResponseParser(), + httpResponseDecoder: sentryResponseDecoder, + ), +]); + +Map? sentryResponseDecoder( + http.Response response, { + Hub? hub, +}) { + final currentHub = hub ?? HubAdapter(); + final span = currentHub.getSpan()?.startChild( + 'serialize.http.client', + description: 'http response deserialization', + ); + Map? result; + try { + result = _defaultHttpResponseDecoder(response); + span?.status = const SpanStatus.ok(); + } catch (e) { + span?.status = const SpanStatus.unknownError(); + span?.throwable = e; + rethrow; + } finally { + unawaited(span?.finish()); + } + return result; +} + +Map? _defaultHttpResponseDecoder(http.Response httpResponse) { + return json.decode(utf8.decode(httpResponse.bodyBytes)) + as Map?; +} +``` + +
+ +## Filter redundant HTTP breadcrumbs + +If you use the [`sentry_dio`](https://pub.dev/packages/sentry_dio) or [`http`](https://pub.dev/documentation/sentry/latest/sentry_io/SentryHttpClient-class.html) you will have breadcrumbs attached for every HTTP request. In order to not have duplicated breadcrumbs from the HTTP integrations and this GraphQL integration, +you should filter those breadcrumbs. + +That can be achieved in two ways: + +1. Disable all HTTP breadcrumbs. +2. Use [`beforeBreadcrumb`](https://pub.dev/documentation/sentry/latest/sentry_io/SentryOptions/beforeBreadcrumb.html). + ```dart + return Sentry.init( + (options) { + options.beforeBreadcrumb = graphQlFilter(); + // or + options.beforeBreadcrumb = graphQlFilter((breadcrumb, hint) { + // custom filter + return breadcrumb; + }); + }, + ); + ``` + +## Additional `graphql` usage hints + +
+ + +Additional hints for usage with [`graphql`](https://pub.dev/packages/graphql) + + + +```dart +import 'package:sentry/sentry.dart'; +import 'package:sentry_link/sentry_link.dart'; +import 'package:graphql/graphql.dart'; + +Sentry.init((options) { + options.addExceptionCauseExtractor(UnknownExceptionExtractor()); + options.addExceptionCauseExtractor(NetworkExceptionExtractor()); + options.addExceptionCauseExtractor(CacheMissExceptionExtractor()); + options.addExceptionCauseExtractor(OperationExceptionExtractor()); + options.addExceptionCauseExtractor(CacheMisconfigurationExceptionExtractor()); + options.addExceptionCauseExtractor(MismatchedDataStructureExceptionExtractor()); + options.addExceptionCauseExtractor(UnexpectedResponseStructureExceptionExtractor()); +}); + +class UnknownExceptionExtractor + extends LinkExceptionExtractor {} + +class NetworkExceptionExtractor + extends LinkExceptionExtractor {} + +class CacheMissExceptionExtractor + extends LinkExceptionExtractor {} + +class CacheMisconfigurationExceptionExtractor + extends LinkExceptionExtractor {} + +class MismatchedDataStructureExceptionExtractor + extends LinkExceptionExtractor {} + +class UnexpectedResponseStructureExceptionExtractor + extends LinkExceptionExtractor {} + +class OperationExceptionExtractor extends ExceptionCauseExtractor { + @override + ExceptionCause? cause(T error) { + return ExceptionCause(error.linkException, error.originalStackTrace); + } +} +``` + +
+ +# 📣 About the original author + +- [![Twitter Follow](https://img.shields.io/twitter/follow/ue_man?style=social)](https://twitter.com/ue_man) +- [![GitHub followers](https://img.shields.io/github/followers/ueman?style=social)](https://github.com/ueman) diff --git a/link/analysis_options.yaml b/link/analysis_options.yaml new file mode 100644 index 0000000000..c127277900 --- /dev/null +++ b/link/analysis_options.yaml @@ -0,0 +1,13 @@ +include: package:lints/recommended.yaml + +linter: + rules: + - prefer_single_quotes + - depend_on_referenced_packages + - always_use_package_imports + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true \ No newline at end of file diff --git a/link/example/example.dart b/link/example/example.dart new file mode 100644 index 0000000000..bc5ef7cee6 --- /dev/null +++ b/link/example/example.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:graphql/client.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_link/sentry_link.dart'; + +Future main() { + return Sentry.init( + (options) { + options.dsn = 'sentry_dsn'; + options.tracesSampleRate = 1; + options.beforeBreadcrumb = graphQlFilter(); + options.addGqlExtractors(); + options.addSentryLinkInAppExcludes(); + }, + appRunner: example, + ); +} + +Future example() async { + final link = Link.from([ + SentryGql.link( + shouldStartTransaction: true, + graphQlErrorsMarkTransactionAsFailed: true, + ), + HttpLink( + 'https://graphqlzero.almansi.me/api', + httpClient: SentryHttpClient(), + parser: SentryResponseParser(), + serializer: SentryRequestSerializer(), + ), + ]); + + final client = GraphQLClient( + cache: GraphQLCache(), + link: link, + ); + + final QueryOptions options = QueryOptions( + operationName: 'LoadPosts', + document: gql( + r''' + query LoadPosts($id: ID!) { + post(id: $id) { + id + # this one is intentionally wrong, the last char 'e' is missing + titl + body + } + } + ''', + ), + variables: { + 'id': 50, + }, + ); + + final result = await client.query(options); + print(result.toString()); + await Future.delayed(Duration(seconds: 2)); + exit(0); +} diff --git a/link/lib/sentry_link.dart b/link/lib/sentry_link.dart new file mode 100644 index 0000000000..5450e4f4ee --- /dev/null +++ b/link/lib/sentry_link.dart @@ -0,0 +1,9 @@ +library sentry_link; + +export 'src/extractors.dart' show GqlExctractors, LinkExceptionExtractor; +export 'src/sentry_request_serializer.dart'; +export 'src/sentry_response_parser.dart'; +export 'src/graph_gl_filter.dart'; +export 'src/sentry_gql.dart'; +export 'src/extension.dart' + hide SentryGraphQLErrorExtension, SentryOperationTypeExtension; diff --git a/link/lib/src/extension.dart b/link/lib/src/extension.dart new file mode 100644 index 0000000000..0c3a0ba704 --- /dev/null +++ b/link/lib/src/extension.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +import 'package:gql/ast.dart'; +import 'package:gql_exec/gql_exec.dart'; +import 'package:sentry/sentry.dart'; +import 'package:gql/language.dart' show printNode; + +/// Extension for [GraphQLError] +extension SentryGraphQLErrorExtension on GraphQLError { + Map toJson() { + return { + 'message': message, + 'locations': + locations?.map((e) => {'line': e.line, 'column': e.column}).toList(), + 'paths': path?.map((e) => e.toString()).toList(), + 'extensions': extensions, + }; + } +} + +/// Extension for [Request] +extension SentryRequestExtension on Request { + Map toJson() { + return { + 'operation': operation.toJson(), + 'variables': variables, + }; + } + + SentryRequest toSentryRequest() { + return SentryRequest( + apiTarget: 'graphql', + data: { + 'query': printNode(operation.document), + 'variables': variables, + 'operationName': operation.operationName + }, + ); + } +} + +/// Extension for [Response] +extension SentryResponseExtension on Response { + Map toJson() { + return { + 'errors': errors?.map((e) => e.toJson()).toList(), + 'data': data, + }; + } + + SentryResponse toSentryResponse(int? statusCode) { + return SentryResponse( + statusCode: statusCode, + data: { + 'errors': errors?.map((e) => e.toJson()).toList(), + 'data': data, + }, + ); + } +} + +/// Extension for [Operation] +extension SentryOperationExtension on Operation { + Map toJson() { + return { + 'name': operationName, + 'document': json.encode(printNode(document)), + }; + } +} + +/// Extension for [OperationType] +extension SentryOperationTypeExtension on OperationType { + /// See https://develop.sentry.dev/sdk/performance/span-operations/ + String get sentryOperation { + return switch (this) { + OperationType.query => 'http.graphql.query', + OperationType.mutation => 'http.graphql.mutation', + OperationType.subscription => 'http.graphql.subscription', + }; + } + + String get sentryType { + return switch (this) { + OperationType.query => 'query', + OperationType.mutation => 'mutation', + OperationType.subscription => 'subscription', + }; + } + + String get name { + return switch (this) { + OperationType.query => 'query', + OperationType.mutation => 'mutation', + OperationType.subscription => 'subscription', + }; + } +} + +/// Extension for [SentryOptions] +extension InAppExclueds on SentryOptions { + /// Sets this library as not in-app frames, to improve stack trace + /// presentation in Sentry. + void addSentryLinkInAppExcludes() { + addInAppExclude('sentry_link'); + } +} diff --git a/link/lib/src/extractors.dart b/link/lib/src/extractors.dart new file mode 100644 index 0000000000..a4953148f7 --- /dev/null +++ b/link/lib/src/extractors.dart @@ -0,0 +1,42 @@ +import 'package:gql_link/gql_link.dart'; +import 'package:sentry/sentry.dart'; + +/// Unfortunately, because extractors are looked up via `Type` in map, +/// each exception needs its own extractor. +/// The extractors are quite a few, so we make it easy to add by exposing a +/// method which adds all of the extractors. +extension GqlExctractors on SentryOptions { + /// Adds various exceptions [ExceptionCauseExtractor] to improve the + /// visualization of the reported exceptions. + void addGqlExtractors() { + addExceptionCauseExtractor(RequestFormatExceptionExtractor()); + addExceptionCauseExtractor(ResponseFormatExceptionExtractor()); + addExceptionCauseExtractor(ContextReadExceptionExtractor()); + addExceptionCauseExtractor(ContextWriteExceptionExtractor()); + addExceptionCauseExtractor(ServerExceptionExtractor()); + } +} + +/// [ExceptionCauseExtractor] for [LinkException]s +class LinkExceptionExtractor + extends ExceptionCauseExtractor { + @override + ExceptionCause? cause(T error) { + return ExceptionCause(error.originalException, error.originalStackTrace); + } +} + +class RequestFormatExceptionExtractor + extends LinkExceptionExtractor {} + +class ResponseFormatExceptionExtractor + extends LinkExceptionExtractor {} + +class ContextReadExceptionExtractor + extends LinkExceptionExtractor {} + +class ContextWriteExceptionExtractor + extends LinkExceptionExtractor {} + +class ServerExceptionExtractor + extends LinkExceptionExtractor {} diff --git a/link/lib/src/graph_gl_filter.dart b/link/lib/src/graph_gl_filter.dart new file mode 100644 index 0000000000..685e9e1508 --- /dev/null +++ b/link/lib/src/graph_gl_filter.dart @@ -0,0 +1,25 @@ +import 'package:sentry/sentry.dart'; + +BeforeBreadcrumbCallback graphQlFilter([BeforeBreadcrumbCallback? filter]) { + return ( + Breadcrumb? ogBreadcrumb, + Hint hint, + ) { + final breadCrumb = + (filter != null) ? filter.call(ogBreadcrumb, hint) : ogBreadcrumb; + if (breadCrumb == null) { + return null; + } + + if (!(breadCrumb.type == 'http' && breadCrumb.category == 'http')) { + return breadCrumb; + } + + final url = breadCrumb.data?['url'] as String?; + if (url?.contains('/graphql') ?? false) { + // filter any request to "https://example.org/graphql" + return null; + } + return breadCrumb; + }; +} diff --git a/link/lib/src/sentry_breadcrumb_link.dart b/link/lib/src/sentry_breadcrumb_link.dart new file mode 100644 index 0000000000..d775f5f9bc --- /dev/null +++ b/link/lib/src/sentry_breadcrumb_link.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_link/src/sentry_link.dart'; +import 'package:sentry_link/src/extension.dart'; + +/// Only handles success cases. Error cases are handled by [SentryLink]. +class SentryBreadcrumbLink extends Link { + /// Adds breadcrumbs for GraphQL operations + SentryBreadcrumbLink({Hub? hub}) : _hub = hub ?? HubAdapter(); + + final Hub _hub; + + @override + Stream request(Request request, [NextLink? forward]) { + assert( + forward != null, + 'This is not a terminating link and needs a NextLink', + ); + + final operationType = request.operation.getOperationType()?.sentryType; + final description = + 'GraphQL: "${request.operation.operationName ?? 'unnamed'}" $operationType'; + + final stopwatch = Stopwatch()..start(); + + return forward!(request).transform(StreamTransformer.fromHandlers( + handleData: (data, sink) { + stopwatch.stop(); + // Errors are handled by SentryLink, so opt-out if there are errors. + if (data.errors == null) { + _addBreadcrumb(description, stopwatch.elapsed, data); + } + sink.add(data); + }, + handleError: (error, stackTrace, sink) { + // Error handling can be significantly improved after + // https://github.com/gql-dart/gql/issues/361 + // is done. + stopwatch.stop(); + sink.addError(error, stackTrace); + }, + )); + } + + void _addBreadcrumb( + String description, + Duration duration, + Response response, + ) { + _hub.addBreadcrumb(Breadcrumb( + category: 'GraphQL', + message: description, + type: 'query', + data: {'duration': duration.toString()}, + )); + } +} diff --git a/link/lib/src/sentry_gql.dart b/link/lib/src/sentry_gql.dart new file mode 100644 index 0000000000..0196e6f0fe --- /dev/null +++ b/link/lib/src/sentry_gql.dart @@ -0,0 +1,47 @@ +import 'package:gql_link/gql_link.dart'; +import 'package:gql_exec/gql_exec.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_link/src/sentry_breadcrumb_link.dart'; +import 'package:sentry_link/src/sentry_link.dart'; +import 'package:sentry_link/src/sentry_tracing_link.dart'; + +abstract class SentryGql { + SentryGql._(); + + // Provide a single Link, which is combines all the various links and makes + // them configurable. + /// If [shouldStartTransaction] is set to true, a [SentryTransaction] + /// is automatically created for each GraphQL query/mutation. + /// If a transaction is already bound to scope, no [SentryTransaction] + /// will be started even if [shouldStartTransaction] is set to true. + /// + /// If [graphQlErrorsMarkTransactionAsFailed] is set to true and a + /// query or mutation have a [GraphQLError] attached, the current + /// [SentryTransaction] is marked as with [SpanStatus.unknownError]. + static Link link({ + bool enableBreadcrumbs = true, + required bool shouldStartTransaction, + required bool graphQlErrorsMarkTransactionAsFailed, + bool reportExceptions = true, + bool reportExceptionsAsBreadcrumbs = false, + bool reportGraphQlErrors = true, + bool reportGraphQlErrorsAsBreadcrumbs = false, + }) { + return Link.from([ + SentryLink.link( + reportExceptions: reportExceptions, + reportExceptionsAsBreadcrumbs: reportExceptionsAsBreadcrumbs, + reportGraphQlErrors: reportGraphQlErrors, + reportGraphQlErrorsAsBreadcrumbs: reportExceptionsAsBreadcrumbs, + ), + if (enableBreadcrumbs) SentryBreadcrumbLink(), + if (shouldStartTransaction != false && + graphQlErrorsMarkTransactionAsFailed != false) + SentryTracingLink( + graphQlErrorsMarkTransactionAsFailed: + graphQlErrorsMarkTransactionAsFailed, + shouldStartTransaction: shouldStartTransaction, + ), + ]); + } +} diff --git a/link/lib/src/sentry_link.dart b/link/lib/src/sentry_link.dart new file mode 100644 index 0000000000..af6431b76a --- /dev/null +++ b/link/lib/src/sentry_link.dart @@ -0,0 +1,148 @@ +import 'package:gql_error_link/gql_error_link.dart'; +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_link/src/extension.dart'; + +/// Provides a [Link] which captures exceptions and GraphQL errors +class SentryLink { + /// Provides a [Link] which captures exceptions and GraphQL errors. + static ErrorLink link({ + Hub? hub, + bool reportExceptions = true, + bool reportExceptionsAsBreadcrumbs = false, + bool reportGraphQlErrors = true, + bool reportGraphQlErrorsAsBreadcrumbs = false, + }) { + hub = hub ?? HubAdapter(); + + final handler = SentryLinkHandler( + hub: hub, + reportExceptions: reportExceptions, + reportExceptionsAsBreadcrumbs: reportExceptionsAsBreadcrumbs, + reportGraphQLErrors: reportGraphQlErrors, + reportGraphQlErrorsAsBreadcrumbs: reportGraphQlErrorsAsBreadcrumbs, + ); + + return ErrorLink( + onException: handler.onException, + onGraphQLError: handler.onGraphQlError, + ); + } +} + +// See https://github.com/gql-dart/gql/blob/master/links/gql_error_link/lib/gql_error_link.dart +class SentryLinkHandler { + const SentryLinkHandler({ + required this.hub, + required this.reportExceptions, + required this.reportExceptionsAsBreadcrumbs, + required this.reportGraphQLErrors, + required this.reportGraphQlErrorsAsBreadcrumbs, + }); + + final Hub hub; + + final bool reportExceptions; + final bool reportExceptionsAsBreadcrumbs; + + final bool reportGraphQLErrors; + final bool reportGraphQlErrorsAsBreadcrumbs; + + Stream? onGraphQlError( + Request request, + NextLink forward, + Response response, + ) async* { + final errors = response.errors; + if (errors == null) { + yield response; + return; + } + if (reportGraphQlErrorsAsBreadcrumbs) { + hub.addBreadcrumb(Breadcrumb( + level: SentryLevel.error, + category: 'GraphQLError', + type: 'error', + data: { + 'request': request.toJson(), + 'response': response.toJson(), + }, + )); + } else if (reportGraphQLErrors) { + final event = _eventFromRequestAndResponse( + request: request, + response: response, + ); + + await hub.captureEvent(event); + } + + yield response; + } + + Stream? onException( + Request request, + NextLink forward, + LinkException exception, + ) async* { + if (reportExceptionsAsBreadcrumbs) { + hub.addBreadcrumb(Breadcrumb( + message: exception.toString(), + level: SentryLevel.error, + category: 'LinkException', + type: 'error', + data: request.toJson(), + )); + } else if (reportExceptions) { + Response? response; + int? statusCode; + if (exception is ServerException) { + response = exception.parsedResponse; + statusCode = exception.statusCode; + } + + final event = _eventFromRequestAndResponse( + request: request, + response: response, + statusCode: statusCode, + exception: exception, + ); + + await hub.captureEvent(event); + } + yield* Stream.error(exception); + } +} + +SentryEvent _eventFromRequestAndResponse({ + required Request request, + required Response? response, + int? statusCode, + Object? exception, +}) { + final sentryRequest = request.toSentryRequest(); + final operationName = request.operation.operationName ?? 'unnamed operation'; + final type = request.operation.getOperationType(); + + final sentryResponse = response?.toSentryResponse(statusCode); + ThrowableMechanism? throwableMechanism; + if (exception != null) { + final mechanism = Mechanism( + type: 'SentryLink', + handled: true, + ); + throwableMechanism = ThrowableMechanism(mechanism, exception); + } + + return SentryEvent( + message: SentryMessage('Error during $operationName'), + level: SentryLevel.error, + request: sentryRequest, + contexts: Contexts(response: sentryResponse), + fingerprint: [operationName, type?.name, statusCode?.toString()] + .whereType() + .toList(), + throwable: throwableMechanism, + ); +} diff --git a/link/lib/src/sentry_request_serializer.dart b/link/lib/src/sentry_request_serializer.dart new file mode 100644 index 0000000000..1d766bd900 --- /dev/null +++ b/link/lib/src/sentry_request_serializer.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:sentry/sentry.dart'; + +class SentryRequestSerializer implements RequestSerializer { + SentryRequestSerializer({RequestSerializer? inner, Hub? hub}) + : inner = inner ?? const RequestSerializer(), + _hub = hub ?? HubAdapter(); + + final RequestSerializer inner; + final Hub _hub; + + @override + Map serializeRequest(Request request) { + final span = _hub.getSpan()?.startChild( + 'serialize.http.client', + description: 'GraphGL request serialization', + ); + Map result; + try { + result = inner.serializeRequest(request); + span?.status = const SpanStatus.ok(); + } catch (e) { + span?.status = const SpanStatus.unknownError(); + span?.throwable = e; + rethrow; + } finally { + unawaited(span?.finish()); + } + return result; + } +} diff --git a/link/lib/src/sentry_response_parser.dart b/link/lib/src/sentry_response_parser.dart new file mode 100644 index 0000000000..ab02e7f46a --- /dev/null +++ b/link/lib/src/sentry_response_parser.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:sentry/sentry.dart'; + +class SentryResponseParser implements ResponseParser { + SentryResponseParser({ResponseParser? inner, Hub? hub}) + : inner = inner ?? const ResponseParser(), + _hub = hub ?? HubAdapter(); + + final ResponseParser inner; + final Hub _hub; + + @override + Response parseResponse(Map body) { + final span = _hub.getSpan()?.startChild( + 'serialize.http.client', + description: 'Response deserialization ' + 'from JSON map to Response object', + ); + Response result; + try { + result = inner.parseResponse(body); + span?.status = const SpanStatus.ok(); + } catch (e) { + span?.status = const SpanStatus.unknownError(); + span?.throwable = e; + rethrow; + } finally { + unawaited(span?.finish()); + } + return result; + } + + @override + GraphQLError parseError(Map error) => + inner.parseError(error); + + @override + ErrorLocation parseLocation(Map location) => + inner.parseLocation(location); +} diff --git a/link/lib/src/sentry_tracing_link.dart b/link/lib/src/sentry_tracing_link.dart new file mode 100644 index 0000000000..93c420eed9 --- /dev/null +++ b/link/lib/src/sentry_tracing_link.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_link/src/extension.dart'; + +class SentryTracingLink extends Link { + /// If [shouldStartTransaction] is set to true, a [SentryTransaction] + /// is automatically created for each GraphQL query/mutation. + /// If a transaction is already bound to scope, no [SentryTransaction] + /// will be started even if [shouldStartTransaction] is set to true. + /// + /// If [graphQlErrorsMarkTransactionAsFailed] is set to true and a + /// query or mutation have a [GraphQLError] attached, the current + /// [SentryTransaction] is marked as with [SpanStatus.unknownError]. + SentryTracingLink({ + required this.shouldStartTransaction, + required this.graphQlErrorsMarkTransactionAsFailed, + Hub? hub, + }) : _hub = hub ?? HubAdapter(); + + final Hub _hub; + + /// If [shouldStartTransaction] is set to true, a [SentryTransaction] + /// is automatically created for each GraphQL query/mutation. + /// If a transaction is already bound to scope, no [SentryTransaction] + /// will be started even if [shouldStartTransaction] is set to true. + final bool shouldStartTransaction; + + /// If [graphQlErrorsMarkTransactionAsFailed] is set to true and a + /// query or mutation have a [GraphQLError] attached, the current + /// [SentryTransaction] is marked as with [SpanStatus.unknownError]. + final bool graphQlErrorsMarkTransactionAsFailed; + + @override + Stream request(Request request, [NextLink? forward]) { + assert( + forward != null, + 'This is not a terminating link and needs a NextLink', + ); + + final operationType = request.operation.getOperationType(); + final sentryOperation = operationType?.sentryOperation ?? 'unknown'; + final sentryType = operationType?.sentryType; + + final transaction = _startSpan( + 'GraphQL: "${request.operation.operationName ?? 'unnamed'}" $sentryType', + sentryOperation, + shouldStartTransaction, + ); + return forward!(request).transform(StreamTransformer.fromHandlers( + handleData: (data, sink) { + final hasGraphQlError = data.errors?.isNotEmpty ?? false; + if (graphQlErrorsMarkTransactionAsFailed && hasGraphQlError) { + transaction?.finish(status: const SpanStatus.unknownError()); + } else { + transaction?.finish(status: const SpanStatus.ok()); + } + + sink.add(data); + }, + handleError: (error, stackTrace, sink) { + // Error handling can be significantly improved after + // https://github.com/gql-dart/gql/issues/361 + // is done. + // The correct `SpanStatus` can be set on + // `HttpLinkResponseContext.statusCode` or + // `DioLinkResponseContext.statusCode` + transaction?.throwable = error; + unawaited(transaction?.finish(status: const SpanStatus.unknownError())); + + sink.addError(error, stackTrace); + }, + )); + } + + ISentrySpan? _startSpan( + String op, + String description, + bool shouldStartTransaction, + ) { + final span = _hub.getSpan(); + if (span == null && shouldStartTransaction) { + return _hub.startTransaction(description, op, bindToScope: true); + } else if (span != null) { + return span.startChild(op, description: description); + } + return null; + } +} diff --git a/link/pubspec.yaml b/link/pubspec.yaml new file mode 100644 index 0000000000..ad654e88bc --- /dev/null +++ b/link/pubspec.yaml @@ -0,0 +1,21 @@ +name: sentry_link +description: Automatic capture of exceptions and GraphQL errors for the gql eco-system, like graphql and ferry +version: 0.5.2 +homepage: https://docs.sentry.io/platforms/dart/ +repository: https://github.com/getsentry/sentry-dart +issue_tracker: https://github.com/getsentry/sentry-dart/issues + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + gql_error_link: ">=0.2.0 <2.0.0" + gql_exec: ">=0.4.4 <2.0.0" + gql_link: ">=0.5.0 <2.0.0" + gql: ">=0.14.0 <2.0.0" + sentry: ^8.0.0 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.16.0 + graphql: ^5.1.3 diff --git a/link/screenshot.png b/link/screenshot.png new file mode 100644 index 0000000000..107cb79007 Binary files /dev/null and b/link/screenshot.png differ diff --git a/link/test/graph_gl_filter.dart b/link/test/graph_gl_filter.dart new file mode 100644 index 0000000000..8b679c5fed --- /dev/null +++ b/link/test/graph_gl_filter.dart @@ -0,0 +1,22 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry_link/sentry_link.dart'; +import 'package:test/test.dart'; + +void main() { + test('GraphQL urls should be filtered', () { + final result = graphQlFilter()( + Breadcrumb.http( + url: Uri.parse('https://example.org/graphql'), method: 'get'), + Hint(), + ); + expect(result, null); + }); + + test('non GraphQL urls should not be filtered', () { + final result = graphQlFilter()( + Breadcrumb.http(url: Uri.parse('https://example.org/'), method: 'get'), + Hint(), + ); + expect(result, isNotNull); + }); +} diff --git a/link/test/link_exception_extractor.dart b/link/test/link_exception_extractor.dart new file mode 100644 index 0000000000..d44f277964 --- /dev/null +++ b/link/test/link_exception_extractor.dart @@ -0,0 +1,17 @@ +import 'package:gql_link/gql_link.dart'; +import 'package:sentry_link/src/extractors.dart'; +import 'package:test/test.dart'; + +void main() { + test('extractor can extract', () { + final nestedException = LinkExceptionExtractor().cause( + ResponseFormatException( + originalException: Exception(), + originalStackTrace: StackTrace.current, + ), + ); + + expect(nestedException?.exception, isA()); + expect(nestedException?.stackTrace, isNotNull); + }); +}