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) | |
+| [`graphql`](https://pub.dev/packages/graphql) | |
+| [`ferry`](https://pub.dev/packages/ferry) | |
+| [`artemis`](https://pub.dev/packages/artemis) | |
+
+## 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);
+ });
+}