diff --git a/.craft.yml b/.craft.yml
index de509f4f7b..9772474adc 100644
--- a/.craft.yml
+++ b/.craft.yml
@@ -19,6 +19,7 @@ targets:
isar:
link:
firebase_remote_config:
+ supabase:
- name: github
- name: registry
sdks:
@@ -33,3 +34,5 @@ targets:
pub:sentry_isar:
pub:sentry_link:
pub:sentry_firebase_remote_config:
+ # TODO: after we published supabase we need to add it to the registry repo and then uncomment here
+ # pub:sentry_supabase:
diff --git a/.github/workflows/diagrams.yml b/.github/workflows/diagrams.yml
index 06e9754282..db215a77cb 100644
--- a/.github/workflows/diagrams.yml
+++ b/.github/workflows/diagrams.yml
@@ -59,6 +59,10 @@ jobs:
working-directory: ./firebase_remote_config
run: lakos . -i "{test/**,example/**}" | dot -Tsvg -o class-diagram.svg
+ - name: supabase
+ working-directory: ./supabase
+ run: lakos . -i "{test/**,example/**}" | dot -Tsvg -o class-diagram.svg
+
# Source: https://stackoverflow.com/a/58035262
- name: Extract branch name
shell: bash
diff --git a/.github/workflows/supabase.yml b/.github/workflows/supabase.yml
new file mode 100644
index 0000000000..c7fbbd79ea
--- /dev/null
+++ b/.github/workflows/supabase.yml
@@ -0,0 +1,56 @@
+name: sentry-supabase
+on:
+ push:
+ branches:
+ - main
+ - release/**
+ pull_request:
+ paths:
+ - '!**/*.md'
+ - '!**/class-diagram.svg'
+ - '.github/workflows/supabase.yml'
+ - '.github/workflows/analyze.yml'
+ - '.github/actions/dart-test/**'
+ - '.github/actions/coverage/**'
+ - 'dart/**'
+ - 'flutter/**'
+ - 'supabase/**'
+
+# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: '${{ matrix.os }} | ${{ matrix.sdk }}'
+ runs-on: ${{ matrix.os }}-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos, ubuntu, windows]
+ sdk: [stable, beta]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: ./.github/actions/flutter-test
+ with:
+ directory: supabase
+ web: false
+
+# TODO: don't set coverage for now to finish publishing it
+# - uses: ./.github/actions/coverage
+# if: runner.os == 'Linux' && matrix.sdk == 'stable'
+# with:
+# token: ${{ secrets.CODECOV_TOKEN }}
+# directory: supabase
+# coverage: sentry_supabase
+# min-coverage: 55
+
+ analyze:
+ uses: ./.github/workflows/analyze.yml
+ with:
+ package: supabase
+ sdk: flutter
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 316a32d6ce..291f7388de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@
- Add os and device attributes to Flutter logs ([#2978](https://github.com/getsentry/sentry-dart/pull/2978))
- String templating for structured logs ([#3002](https://github.com/getsentry/sentry-dart/pull/3002))
- Add user attributes to Dart/Flutter logs ([#3014](https://github.com/getsentry/sentry-dart/pull/3002))
+- Sentry Supabase Integration ([#2913](https://github.com/getsentry/sentry-dart/pull/2913))
+ - Adds the `sentry_supabase` package to instrument supabase with Sentry breadcrumbs, traces and errors.
## 9.1.0
diff --git a/dart/lib/src/sentry_trace_origins.dart b/dart/lib/src/sentry_trace_origins.dart
index 23359bf9f2..4c8c9ad137 100644
--- a/dart/lib/src/sentry_trace_origins.dart
+++ b/dart/lib/src/sentry_trace_origins.dart
@@ -27,4 +27,5 @@ class SentryTraceOrigins {
static const autoDbDriftQueryInterceptor = 'auto.db.drift.query.interceptor';
static const autoUiTimeToDisplay = 'auto.ui.time_to_display';
static const manualUiTimeToDisplay = 'manual.ui.time_to_display';
+ static const autoDbSupabase = 'auto.db.supabase';
}
diff --git a/flutter/example/pubspec_overrides.yaml b/flutter/example/pubspec_overrides.yaml
index 8f3cdc6729..35a595cd51 100644
--- a/flutter/example/pubspec_overrides.yaml
+++ b/flutter/example/pubspec_overrides.yaml
@@ -27,3 +27,4 @@ dependency_overrides:
isar_generator:
version: ^3.1.0
hosted: https://pub.isar-community.dev/
+
diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh
index 4b495014ed..75402caa9f 100755
--- a/scripts/bump-version.sh
+++ b/scripts/bump-version.sh
@@ -10,7 +10,7 @@ NEW_VERSION="${2}"
echo "Current version: ${OLD_VERSION}"
echo "Bumping version: ${NEW_VERSION}"
-for pkg in {dart,flutter,logging,dio,file,sqflite,drift,hive,isar,link,firebase_remote_config}; do
+for pkg in {dart,flutter,logging,dio,file,sqflite,drift,hive,isar,link,firebase_remote_config,supabase}; do
# Bump version in pubspec.yaml
perl -pi -e "s/^version: .*/version: $NEW_VERSION/" $pkg/pubspec.yaml
# Bump sentry dependency version in pubspec.yaml
diff --git a/supabase/.gitignore b/supabase/.gitignore
new file mode 100644
index 0000000000..ba521d5a39
--- /dev/null
+++ b/supabase/.gitignore
@@ -0,0 +1,14 @@
+# Omit committing pubspec.lock for library packages; see
+# https://dart.dev/guides/libraries/private-files#pubspeclock.
+pubspec.lock
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+/build/
diff --git a/supabase/CHANGELOG.md b/supabase/CHANGELOG.md
new file mode 120000
index 0000000000..04c99a55ca
--- /dev/null
+++ b/supabase/CHANGELOG.md
@@ -0,0 +1 @@
+../CHANGELOG.md
\ No newline at end of file
diff --git a/supabase/LICENSE b/supabase/LICENSE
new file mode 100644
index 0000000000..2a6964d84d
--- /dev/null
+++ b/supabase/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 Sentry
+
+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/supabase/README.md b/supabase/README.md
new file mode 100644
index 0000000000..908191b549
--- /dev/null
+++ b/supabase/README.md
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+===========
+
+
+
+
+
+
+
+
+Sentry integration for `supabase` package
+===========
+
+| package | build | pub | likes | popularity | pub points |
+|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------| ------- |
+| sentry_supabase | [](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-supabase) | [](https://pub.dev/packages/sentry_supabase) | [](https://pub.dev/packages/sentry_supabase/score) | [](https://pub.dev/packages/sentry_supabase/score) | [](https://pub.dev/packages/sentry_supabase/score)
+
+Integration for [`supabase`](https://pub.dev/packages/supabase) package.
+
+#### Usage
+
+- Sign up for a Sentry.io account and get a DSN at https://sentry.io.
+
+- Follow the installing instructions on [pub.dev](https://pub.dev/packages/sentry/install).
+
+- Initialize the Sentry SDK using the DSN issued by Sentry.io.
+
+- Call...
+
+```dart
+import 'package:supabase_flutter/supabase_flutter.dart';
+import 'package:sentry_supabase/sentry_supabase.dart';
+
+// Create a [SentrySupabaseClient] and pass it to Supabase during initialization.
+
+final sentrySupabaseClient = SentrySupabaseClient();
+await Supabase.initialize(
+ url: '',
+ anonKey: '',
+ httpClient: sentrySupabaseClient,
+);
+
+// Now all [Supabase] operations and queries will
+// be instrumented with Sentry breadcrumbs, traces and errors.
+
+final issues = await Supabase.instance.client
+ .from('issues')
+ .select();
+```
+
+#### Resources
+
+* [](https://docs.sentry.io/platforms/flutter/)
+* [](https://docs.sentry.io/platforms/dart/)
+* [](https://github.com/getsentry/sentry-dart/discussions)
+* [](https://discord.gg/PXa5Apfe7K)
+* [](https://stackoverflow.com/questions/tagged/sentry)
+* [](https://twitter.com/intent/follow?screen_name=getsentry)
diff --git a/supabase/analysis_options.yaml b/supabase/analysis_options.yaml
new file mode 100644
index 0000000000..7119dc352d
--- /dev/null
+++ b/supabase/analysis_options.yaml
@@ -0,0 +1,32 @@
+include: package:lints/recommended.yaml
+
+analyzer:
+ language:
+ strict-casts: true
+ strict-inference: true
+ strict-raw-types: true
+ errors:
+ # treat missing required parameters as a warning (not a hint)
+ missing_required_param: error
+ # treat missing returns as a warning (not a hint)
+ missing_return: error
+ # allow having TODOs in the code
+ todo: ignore
+ # allow self-reference to deprecated members (we do this because otherwise we have
+ # to annotate every member in every test, assert, etc, when we deprecate something)
+ deprecated_member_use_from_same_package: warning
+ # ignore sentry/path on pubspec as we change it on deployment
+ invalid_dependency: ignore
+ exclude:
+ - example/**
+ - test/mocks/mocks.mocks.dart
+
+linter:
+ rules:
+ - prefer_final_locals
+ - prefer_single_quotes
+ - prefer_relative_imports
+ - unnecessary_brace_in_string_interps
+ - implementation_imports
+ - require_trailing_commas
+ - unawaited_futures
diff --git a/supabase/dartdoc_options.yaml b/supabase/dartdoc_options.yaml
new file mode 120000
index 0000000000..7cbb8c0d74
--- /dev/null
+++ b/supabase/dartdoc_options.yaml
@@ -0,0 +1 @@
+../dart/dartdoc_options.yaml
\ No newline at end of file
diff --git a/supabase/example/supabase_example.dart b/supabase/example/supabase_example.dart
new file mode 100644
index 0000000000..f60a2f9004
--- /dev/null
+++ b/supabase/example/supabase_example.dart
@@ -0,0 +1,18 @@
+import 'package:supabase_flutter/supabase_flutter.dart';
+import 'package:sentry_supabase/sentry_supabase.dart';
+
+// Create a [SentrySupabaseClient] and pass it to Supabase during initialization.
+
+final sentrySupabaseClient = SentrySupabaseClient();
+await Supabase.initialize(
+ url: '',
+ anonKey: '',
+ httpClient: sentrySupabaseClient,
+);
+
+// Now all [Supabase] operations and queries will
+// be instrumented with Sentry breadcrumbs, traces and errors.
+
+final issues = await Supabase.instance.client
+ .from('issues')
+ .select();
diff --git a/supabase/lib/sentry_supabase.dart b/supabase/lib/sentry_supabase.dart
new file mode 100644
index 0000000000..eb796b2e8e
--- /dev/null
+++ b/supabase/lib/sentry_supabase.dart
@@ -0,0 +1,3 @@
+library;
+
+export 'src/sentry_supabase_client.dart';
diff --git a/supabase/lib/src/operation.dart b/supabase/lib/src/operation.dart
new file mode 100644
index 0000000000..f698fde8c4
--- /dev/null
+++ b/supabase/lib/src/operation.dart
@@ -0,0 +1,11 @@
+enum Operation {
+ select('select'),
+ insert('insert'),
+ upsert('upsert'),
+ update('update'),
+ delete('delete'),
+ unknown('unknown');
+
+ final String value;
+ const Operation(this.value);
+}
diff --git a/supabase/lib/src/sentry_supabase_breadcrumb_client.dart b/supabase/lib/src/sentry_supabase_breadcrumb_client.dart
new file mode 100644
index 0000000000..6cddaa5990
--- /dev/null
+++ b/supabase/lib/src/sentry_supabase_breadcrumb_client.dart
@@ -0,0 +1,50 @@
+import 'package:http/http.dart';
+import 'package:sentry/sentry.dart';
+
+import 'sentry_supabase_request.dart';
+
+class SentrySupabaseBreadcrumbClient extends BaseClient {
+ final Client _innerClient;
+ final Hub _hub;
+
+ SentrySupabaseBreadcrumbClient(this._innerClient, this._hub);
+
+ @override
+ Future send(BaseRequest request) async {
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request);
+
+ if (supabaseRequest == null) {
+ return _innerClient.send(request);
+ }
+
+ final breadcrumb = Breadcrumb(
+ message: 'from(${supabaseRequest.table})',
+ category: 'db.${supabaseRequest.operation.value}',
+ type: 'supabase',
+ );
+
+ breadcrumb.data ??= {};
+
+ breadcrumb.data?['table'] = supabaseRequest.table;
+ breadcrumb.data?['operation'] = supabaseRequest.operation.value;
+
+ // ignore: invalid_use_of_internal_member
+ if (supabaseRequest.query.isNotEmpty && _hub.options.sendDefaultPii) {
+ breadcrumb.data?['query'] = supabaseRequest.query;
+ }
+
+ // ignore: invalid_use_of_internal_member
+ if (supabaseRequest.body != null && _hub.options.sendDefaultPii) {
+ breadcrumb.data?['body'] = supabaseRequest.body;
+ }
+
+ await _hub.addBreadcrumb(breadcrumb);
+
+ return _innerClient.send(request);
+ }
+
+ @override
+ void close() {
+ _innerClient.close();
+ }
+}
diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart
new file mode 100644
index 0000000000..8b7311cb4a
--- /dev/null
+++ b/supabase/lib/src/sentry_supabase_client.dart
@@ -0,0 +1,87 @@
+import 'package:http/http.dart';
+import 'package:sentry/sentry.dart';
+
+import 'sentry_supabase_breadcrumb_client.dart';
+import 'sentry_supabase_tracing_client.dart';
+import 'sentry_supabase_error_client.dart';
+
+/// A [http](https://pub.dev/packages/http)-package compatible HTTP client that
+/// instruments requests to Supabase.
+///
+/// It adds breadcrumbs, tracing and error capturing per default.
+///
+/// ```dart
+/// import 'package:sentry/sentry.dart';
+/// import 'package:sentry_supabase/sentry_supabase.dart';
+///
+/// var supabase = SupabaseClient(
+/// 'https://example.com',
+/// SentrySupabaseClient();
+/// );
+/// ```
+///
+/// You can disable any of the features by setting the `breadcrumbs`, `tracing`
+/// or `errors` parameters to `false`.
+///
+/// ```dart
+/// var supabase = SupabaseClient(
+/// 'https://example.com',
+/// SentrySupabaseClient(
+/// enableBreadcrumbs: false,
+/// enableTracing: false,
+/// enableErrors: true,
+/// ),
+/// );
+/// ```
+///
+/// You can also pass a custom [Client] to the constructor, just like you'd
+/// pass it to the [SupabaseClient] constructor.
+///
+/// ```dart
+/// var supabase = SupabaseClient(
+/// 'https://example.com',
+/// SentrySupabaseClient(client: CustomClient()),
+/// );
+/// ```
+///
+/// Body data will not be sent by default. You can enable it by setting the
+/// `sendDefaultPii` option in the [SentryOptions].
+class SentrySupabaseClient extends BaseClient {
+ final bool _enableBreadcrumbs;
+ final bool _enableTracing;
+ final bool _enableErrors;
+
+ Client _innerClient;
+ final Hub _hub;
+
+ SentrySupabaseClient({
+ bool enableBreadcrumbs = true,
+ bool enableTracing = true,
+ bool enableErrors = true,
+ Client? client,
+ Hub? hub,
+ }) : _enableBreadcrumbs = enableBreadcrumbs,
+ _enableTracing = enableTracing,
+ _enableErrors = enableErrors,
+ _innerClient = client ?? Client(),
+ _hub = hub ?? HubAdapter();
+
+ @override
+ Future send(BaseRequest request) async {
+ if (_enableBreadcrumbs) {
+ _innerClient = SentrySupabaseBreadcrumbClient(_innerClient, _hub);
+ }
+ if (_enableTracing) {
+ _innerClient = SentrySupabaseTracingClient(_innerClient, _hub);
+ }
+ if (_enableErrors) {
+ _innerClient = SentrySupabaseErrorClient(_innerClient, _hub);
+ }
+ return _innerClient.send(request);
+ }
+
+ @override
+ void close() {
+ _innerClient.close();
+ }
+}
diff --git a/supabase/lib/src/sentry_supabase_client_error.dart b/supabase/lib/src/sentry_supabase_client_error.dart
new file mode 100644
index 0000000000..a2643b74ab
--- /dev/null
+++ b/supabase/lib/src/sentry_supabase_client_error.dart
@@ -0,0 +1,7 @@
+class SentrySupabaseClientError implements Exception {
+ final String _message;
+ SentrySupabaseClientError(this._message);
+
+ @override
+ String toString() => 'Exception: $_message';
+}
diff --git a/supabase/lib/src/sentry_supabase_error_client.dart b/supabase/lib/src/sentry_supabase_error_client.dart
new file mode 100644
index 0000000000..0479b6e65e
--- /dev/null
+++ b/supabase/lib/src/sentry_supabase_error_client.dart
@@ -0,0 +1,79 @@
+import 'package:http/http.dart';
+import 'package:sentry/sentry.dart';
+import 'sentry_supabase_client_error.dart';
+
+import 'sentry_supabase_request.dart';
+
+class SentrySupabaseErrorClient extends BaseClient {
+ final Client _innerClient;
+ final Hub _hub;
+
+ SentrySupabaseErrorClient(this._innerClient, this._hub);
+
+ @override
+ Future send(BaseRequest request) async {
+ StreamedResponse? response;
+ dynamic exception;
+ StackTrace? stackTrace;
+ int? statusCode;
+
+ try {
+ response = await _innerClient.send(request);
+ statusCode = response.statusCode;
+ } catch (e, st) {
+ exception = e;
+ stackTrace = st;
+ rethrow;
+ } finally {
+ final hasException = exception != null;
+ final hasErrorResponse = statusCode != null && statusCode >= 400;
+
+ if (hasException || hasErrorResponse) {
+ _captureException(
+ exception,
+ stackTrace,
+ request,
+ response,
+ );
+ }
+ }
+ return response;
+ }
+
+ @override
+ void close() {
+ _innerClient.close();
+ }
+
+ void _captureException(
+ dynamic exception,
+ StackTrace? stackTrace,
+ BaseRequest request,
+ StreamedResponse? response,
+ ) {
+ exception ??= SentrySupabaseClientError(
+ 'Supabase HTTP Client Error with Status Code: ${response?.statusCode}',
+ );
+ final mechanism = Mechanism(type: 'SentrySupabaseClient');
+ final throwable = ThrowableMechanism(mechanism, exception);
+
+ final event = SentryEvent(throwable: throwable);
+ final hint = Hint.withMap({TypeCheckHint.httpRequest: request});
+
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request);
+ if (supabaseRequest != null) {
+ event.contexts['supabase'] = {
+ 'table': supabaseRequest.table,
+ 'operation': supabaseRequest.operation.value,
+ // ignore: invalid_use_of_internal_member
+ if (supabaseRequest.query.isNotEmpty && _hub.options.sendDefaultPii)
+ 'query': supabaseRequest.query,
+ // ignore: invalid_use_of_internal_member
+ if (supabaseRequest.body != null && _hub.options.sendDefaultPii)
+ 'body': supabaseRequest.body,
+ };
+ }
+
+ _hub.captureEvent(event, stackTrace: stackTrace, hint: hint);
+ }
+}
diff --git a/supabase/lib/src/sentry_supabase_request.dart b/supabase/lib/src/sentry_supabase_request.dart
new file mode 100644
index 0000000000..6cafb83f82
--- /dev/null
+++ b/supabase/lib/src/sentry_supabase_request.dart
@@ -0,0 +1,279 @@
+import 'dart:convert';
+import 'package:http/http.dart';
+
+import 'operation.dart';
+
+/// Concepts based on https://github.com/supabase-community/sentry-integration-js
+class SentrySupabaseRequest {
+ final BaseRequest request;
+
+ final String table;
+ final Operation operation;
+ final List query;
+ final Map? body;
+
+ SentrySupabaseRequest({
+ required this.request,
+ required this.table,
+ required this.operation,
+ required this.query,
+ required this.body,
+ });
+
+ static SentrySupabaseRequest? fromRequest(BaseRequest request) {
+ final url = request.url;
+ // Ignoring URLS like https://example.com/auth/v1/token?grant_type=password
+ // Only consider requests to the REST API.
+ if (!url.path.startsWith('/rest/v1')) {
+ return null;
+ }
+ final table = url.pathSegments.last;
+ final operation = _extractOperation(request.method, request.headers);
+ final query = _readQuery(request);
+ final body = _readBody(table, request);
+
+ return SentrySupabaseRequest(
+ request: request,
+ table: table,
+ operation: operation,
+ query: query,
+ body: body,
+ );
+ }
+
+ static Operation _extractOperation(
+ String method,
+ Map headers,
+ ) {
+ switch (method) {
+ case 'GET':
+ return Operation.select;
+ case 'POST':
+ if (headers['Prefer']?.contains('resolution=') ?? false) {
+ return Operation.upsert;
+ } else {
+ return Operation.insert;
+ }
+ case 'PATCH':
+ return Operation.update;
+ case 'DELETE':
+ return Operation.delete;
+ default:
+ return Operation.unknown; // Should never happen.
+ }
+ }
+
+ static List _readQuery(BaseRequest request) {
+ return request.url.queryParametersAll.entries
+ .expand(
+ (entry) => entry.value.map(
+ (value) => _translateFiltersIntoMethods(entry.key, value),
+ ),
+ )
+ .toList();
+ }
+
+ static Map? _readBody(String table, BaseRequest request) {
+ final bodyString =
+ request is Request && request.body.isNotEmpty ? request.body : null;
+ final body = bodyString != null ? jsonDecode(bodyString) : null;
+
+ if (body is Map) {
+ return body;
+ } else {
+ return null;
+ }
+ }
+
+ static const Map _filterMappings = {
+ 'eq': 'eq',
+ 'neq': 'neq',
+ 'gt': 'gt',
+ 'gte': 'gte',
+ 'lt': 'lt',
+ 'lte': 'lte',
+ 'like': 'like',
+ 'like(all)': 'likeAllOf',
+ 'like(any)': 'likeAnyOf',
+ 'ilike': 'ilike',
+ 'ilike(all)': 'ilikeAllOf',
+ 'ilike(any)': 'ilikeAnyOf',
+ 'is': 'is',
+ 'in': 'in',
+ 'cs': 'contains',
+ 'cd': 'containedBy',
+ 'sr': 'rangeGt',
+ 'nxl': 'rangeGte',
+ 'sl': 'rangeLt',
+ 'nxr': 'rangeLte',
+ 'adj': 'rangeAdjacent',
+ 'ov': 'overlaps',
+ 'fts': '',
+ 'plfts': 'plain',
+ 'phfts': 'phrase',
+ 'wfts': 'websearch',
+ 'not': 'not',
+ };
+
+ static String _translateFiltersIntoMethods(String key, String query) {
+ if (query.isEmpty || query == '*') {
+ return 'select(*)';
+ }
+
+ if (key == 'select') {
+ return 'select($query)';
+ }
+
+ if (key == 'or' || key.endsWith('.or')) {
+ return '$key$query';
+ }
+
+ final parts = query.split('.');
+ final filter = parts[0];
+ final value = parts.sublist(1).join('.');
+
+ String method;
+ // Handle optional `configPart` of the filter
+ if (filter.startsWith('fts')) {
+ method = 'textSearch';
+ } else if (filter.startsWith('plfts')) {
+ method = 'textSearch[plain]';
+ } else if (filter.startsWith('phfts')) {
+ method = 'textSearch[phrase]';
+ } else if (filter.startsWith('wfts')) {
+ method = 'textSearch[websearch]';
+ } else {
+ method = _filterMappings[filter] ?? 'filter';
+ }
+ return '$method($key, $value)';
+ }
+
+ /// Generates a SQL query representation for debugging and tracing purposes
+ String generateSqlQuery() {
+ final body = this.body;
+ switch (operation) {
+ case Operation.select:
+ return 'SELECT * FROM "$table"';
+ case Operation.insert:
+ case Operation.upsert:
+ if (body != null && body.isNotEmpty) {
+ final columns = body.keys.map((k) => '"$k"').join(', ');
+ final placeholders = body.keys.map((_) => '?').join(', ');
+ return 'INSERT INTO "$table" ($columns) VALUES ($placeholders)';
+ } else {
+ return 'INSERT INTO "$table" VALUES (?)';
+ }
+ case Operation.update:
+ final setClause = body != null && body.isNotEmpty
+ ? body.keys.map((k) => '"$k" = ?').join(', ')
+ : '?';
+ final whereClause = _buildWhereClause();
+ return 'UPDATE "$table" SET $setClause${whereClause.isNotEmpty ? ' WHERE $whereClause' : ''}';
+ case Operation.delete:
+ final whereClause = _buildWhereClause();
+ return 'DELETE FROM "$table"${whereClause.isNotEmpty ? ' WHERE $whereClause' : ''}';
+ case Operation.unknown:
+ return 'UNKNOWN OPERATION ON "$table"';
+ }
+ }
+
+ /// Builds WHERE clause from query parameters for SQL representation
+ String _buildWhereClause() {
+ final conditions = [];
+
+ // Get original query parameters to help with NOT conditions
+ final originalParams = request.url.queryParameters;
+
+ for (final queryItem in query) {
+ // Skip select queries
+ if (queryItem.startsWith('select(')) continue;
+
+ // Handle OR conditions - e.g., orstatus.eq.inactive or or(id.eq.8)
+ if (queryItem.startsWith('or')) {
+ String orCondition;
+ if (queryItem.startsWith('or(') && queryItem.endsWith(')')) {
+ // Format: or(id.eq.8)
+ orCondition = queryItem.substring(3, queryItem.length - 1);
+ } else if (queryItem.contains('.')) {
+ // Format: orstatus.eq.inactive
+ orCondition = queryItem.substring(2);
+ } else {
+ continue;
+ }
+
+ final orParts = orCondition.split('.');
+ if (orParts.length >= 3) {
+ final column = orParts[0];
+ final operator = orParts[1];
+ final operatorSql = _getOperatorSql(operator);
+ conditions.add('OR $column $operatorSql ?');
+ } else {
+ conditions.add('OR $orCondition = ?');
+ }
+ continue;
+ }
+
+ // Handle NOT conditions via filter - e.g., filter(not, eq.deleted)
+ if (queryItem.startsWith('filter(not,')) {
+ // Find the NOT parameter in original query
+ final notParam = originalParams.entries.firstWhere(
+ (entry) => entry.key == 'not',
+ orElse: () => const MapEntry('not', 'unknown.eq.value'),
+ );
+
+ if (notParam.value.contains('.eq.')) {
+ final parts = notParam.value.split('.eq.');
+ if (parts.isNotEmpty) {
+ final column = parts[0];
+ conditions.add('$column != ?');
+ }
+ }
+ continue;
+ }
+
+ // Handle regular conditions - e.g., eq(id, 42), gt(age, 18), in(status, (active,pending))
+ if (queryItem.contains('(') && queryItem.contains(')')) {
+ final match =
+ RegExp(r'^(\w+)\(([^,]+),\s*(.+)\)$').firstMatch(queryItem);
+ if (match != null) {
+ final operation = match.group(1);
+ final column = match.group(2);
+ if (operation != null && column != null) {
+ final operatorSql = _getOperatorSql(operation);
+ conditions.add('$column $operatorSql ?');
+ }
+ }
+ }
+ }
+
+ return conditions.join(' AND ').replaceAll(' AND OR ', ' OR ');
+ }
+
+ /// Maps filter operations to SQL operators
+ /// This is a subset of the operations supported by Supabase.
+ /// See https://supabase.com/docs/reference/dart/select#filter-operators
+ String _getOperatorSql(String operation) {
+ switch (operation) {
+ case 'eq':
+ return '=';
+ case 'neq':
+ return '!=';
+ case 'gt':
+ return '>';
+ case 'gte':
+ return '>=';
+ case 'lt':
+ return '<';
+ case 'lte':
+ return '<=';
+ case 'like':
+ return 'LIKE';
+ case 'ilike':
+ return 'ILIKE';
+ case 'in':
+ return 'IN';
+ default:
+ return '=';
+ }
+ }
+}
diff --git a/supabase/lib/src/sentry_supabase_tracing_client.dart b/supabase/lib/src/sentry_supabase_tracing_client.dart
new file mode 100644
index 0000000000..1f31bbd62d
--- /dev/null
+++ b/supabase/lib/src/sentry_supabase_tracing_client.dart
@@ -0,0 +1,83 @@
+import 'package:http/http.dart';
+import 'package:sentry/sentry.dart';
+
+import 'sentry_supabase_request.dart';
+
+class SentrySupabaseTracingClient extends BaseClient {
+ final Client _innerClient;
+ final Hub _hub;
+
+ SentrySupabaseTracingClient(this._innerClient, this._hub);
+
+ @override
+ Future send(BaseRequest request) async {
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request);
+ if (supabaseRequest == null) {
+ return _innerClient.send(request);
+ }
+
+ final span = _createSpan(supabaseRequest);
+
+ StreamedResponse? response;
+
+ try {
+ response = await _innerClient.send(request);
+
+ span?.setData('http.response.status_code', response.statusCode);
+ span?.setData('http.response_content_length', response.contentLength);
+ span?.status = SpanStatus.fromHttpStatusCode(response.statusCode);
+ } catch (e) {
+ span?.throwable = e;
+ span?.status = SpanStatus.internalError();
+ rethrow;
+ } finally {
+ await span?.finish();
+ }
+
+ return response;
+ }
+
+ @override
+ void close() {
+ _innerClient.close();
+ }
+
+ // Helper
+
+ ISentrySpan? _createSpan(SentrySupabaseRequest supabaseRequest) {
+ final currentSpan = _hub.getSpan();
+ if (currentSpan == null) {
+ return null;
+ }
+ final span = currentSpan.startChild(
+ 'db.${supabaseRequest.operation.value}',
+ description: 'from(${supabaseRequest.table})',
+ );
+
+ final dbSchema = supabaseRequest.request.headers['Accept-Profile'] ??
+ supabaseRequest.request.headers['Content-Profile'];
+ if (dbSchema != null) {
+ span.setData('db.schema', dbSchema);
+ }
+ span.setData('db.table', supabaseRequest.table);
+ span.setData('db.url', supabaseRequest.request.url.origin);
+ final dbSdk = supabaseRequest.request.headers['X-Client-Info'];
+ if (dbSdk != null) {
+ span.setData('db.sdk', dbSdk);
+ }
+ // ignore: invalid_use_of_internal_member
+ if (supabaseRequest.query.isNotEmpty && _hub.options.sendDefaultPii) {
+ span.setData('db.query', supabaseRequest.query);
+ }
+ // ignore: invalid_use_of_internal_member
+ if (supabaseRequest.body != null && _hub.options.sendDefaultPii) {
+ span.setData('db.body', supabaseRequest.body);
+ }
+ span.setData('db.operation', supabaseRequest.operation.value);
+ span.setData('db.sql.query', supabaseRequest.generateSqlQuery());
+ // ignore: invalid_use_of_internal_member
+ span.setData('origin', SentryTraceOrigins.autoDbSupabase);
+ span.setData('db.system', 'postgres');
+ return span;
+ }
+}
diff --git a/supabase/pubspec.yaml b/supabase/pubspec.yaml
new file mode 100644
index 0000000000..ec483b6709
--- /dev/null
+++ b/supabase/pubspec.yaml
@@ -0,0 +1,18 @@
+name: sentry_supabase
+description: "Sentry integration to instrument Supabase."
+version: 9.0.0-RC.3
+homepage: https://docs.sentry.io/platforms/flutter/
+repository: https://github.com/getsentry/sentry-dart
+issue_tracker: https://github.com/getsentry/sentry-dart/issues
+
+environment:
+ sdk: '>=3.5.0 <4.0.0'
+
+dependencies:
+ http: ^1.3.0
+ sentry: 9.0.0-RC.3
+
+dev_dependencies:
+ supabase: ^2.6.0
+ lints: ^5.0.0
+ test: ^1.24.0
diff --git a/supabase/pubspec_overrides.yaml b/supabase/pubspec_overrides.yaml
new file mode 100644
index 0000000000..16e71d16f0
--- /dev/null
+++ b/supabase/pubspec_overrides.yaml
@@ -0,0 +1,3 @@
+dependency_overrides:
+ sentry:
+ path: ../dart
diff --git a/supabase/test/mocks/mock_client.dart b/supabase/test/mocks/mock_client.dart
new file mode 100644
index 0000000000..9303f6b489
--- /dev/null
+++ b/supabase/test/mocks/mock_client.dart
@@ -0,0 +1,28 @@
+import 'package:http/http.dart';
+import 'dart:convert';
+
+class MockClient extends BaseClient {
+ final sendCalls = [];
+ final closeCalls = [];
+
+ var jsonResponse = '{}';
+ var statusCode = 200;
+ dynamic throwException;
+
+ @override
+ Future send(BaseRequest request) async {
+ sendCalls.add(request);
+ if (throwException != null) {
+ throw throwException;
+ }
+ return StreamedResponse(
+ Stream.value(utf8.encode(jsonResponse)),
+ statusCode,
+ );
+ }
+
+ @override
+ void close() {
+ closeCalls.add(null);
+ }
+}
diff --git a/supabase/test/mocks/mock_hub.dart b/supabase/test/mocks/mock_hub.dart
new file mode 100644
index 0000000000..450119817d
--- /dev/null
+++ b/supabase/test/mocks/mock_hub.dart
@@ -0,0 +1,131 @@
+import 'package:sentry/sentry.dart';
+
+class MockHub implements Hub {
+ MockHub(this._options);
+
+ final SentryOptions _options;
+
+ @override
+ SentryOptions get options => _options;
+
+ // Breadcrumb
+
+ final addBreadcrumbCalls = <(Breadcrumb, Hint?)>[];
+
+ @override
+ Future addBreadcrumb(Breadcrumb crumb, {Hint? hint}) async {
+ addBreadcrumbCalls.add((crumb, hint));
+ }
+
+ // Transaction
+
+ final startTransactionCalls = <(String, String)>[];
+ var mockSpan = _MockSpan();
+ var getSpanCallCount = 0;
+ var currentSpan = _MockSpan();
+
+ @override
+ ISentrySpan startTransaction(
+ String name,
+ String operation, {
+ String? description,
+ DateTime? startTimestamp,
+ bool? bindToScope,
+ bool? waitForChildren,
+ Duration? autoFinishAfter,
+ bool? trimEnd,
+ OnTransactionFinish? onFinish,
+ Map? customSamplingContext,
+ }) {
+ startTransactionCalls.add((name, operation));
+ return mockSpan;
+ }
+
+ @override
+ ISentrySpan? getSpan() {
+ getSpanCallCount++;
+ return currentSpan;
+ }
+
+ // Error
+
+ final captureEventCalls = <(SentryEvent, dynamic, Hint?, ScopeCallback?)>[];
+
+ @override
+ Future captureEvent(
+ SentryEvent event, {
+ dynamic stackTrace,
+ Hint? hint,
+ ScopeCallback? withScope,
+ }) {
+ captureEventCalls.add((event, stackTrace, hint, withScope));
+ return Future.value(SentryId.empty());
+ }
+
+ // No such method
+ @override
+ void noSuchMethod(Invocation invocation) {
+ 'Method ${invocation.memberName} was called '
+ 'with arguments ${invocation.positionalArguments}';
+ }
+}
+
+class _MockSpan implements ISentrySpan {
+ var data = {};
+ var finishCalls = <(SpanStatus?, DateTime?, Hint?)>[];
+
+ var setThrowableCalls = [];
+ var setStatusCalls = [];
+ var startChildCalls = <(String, String?)>[];
+ _MockSpan? _childSpan;
+
+ _MockSpan get childSpan {
+ _childSpan ??= _MockSpan();
+ return _childSpan!;
+ }
+
+ @override
+ void setData(String key, dynamic value) {
+ data[key] = value;
+ }
+
+ @override
+ set throwable(dynamic value) {
+ setThrowableCalls.add(value);
+ }
+
+ @override
+ set status(SpanStatus? value) {
+ setStatusCalls.add(value);
+ }
+
+ @override
+ Future finish({
+ SpanStatus? status,
+ DateTime? endTimestamp,
+ Hint? hint,
+ }) {
+ finishCalls.add((status, endTimestamp, hint));
+ return Future.value();
+ }
+
+ @override
+ ISentrySpan startChild(
+ String operation, {
+ String? description,
+ DateTime? startTimestamp,
+ bool? waitForChildren,
+ Duration? autoFinishAfter,
+ bool? trimEnd,
+ }) {
+ startChildCalls.add((operation, description));
+ return childSpan;
+ }
+
+ // No such method
+ @override
+ void noSuchMethod(Invocation invocation) {
+ 'Method ${invocation.memberName} was called '
+ 'with arguments ${invocation.positionalArguments}';
+ }
+}
diff --git a/supabase/test/sentry_supabase_breadcrumb_client_test.dart b/supabase/test/sentry_supabase_breadcrumb_client_test.dart
new file mode 100644
index 0000000000..ce528eac9a
--- /dev/null
+++ b/supabase/test/sentry_supabase_breadcrumb_client_test.dart
@@ -0,0 +1,231 @@
+import 'dart:convert';
+import 'dart:async';
+
+import 'package:sentry_supabase/src/sentry_supabase_breadcrumb_client.dart';
+import 'package:test/test.dart';
+import 'package:sentry/sentry.dart';
+import 'package:http/http.dart';
+
+import 'package:supabase/supabase.dart';
+import 'mocks/mock_hub.dart';
+
+void main() {
+ late Fixture fixture;
+
+ setUp(() {
+ fixture = Fixture();
+ });
+
+ group('Inner Client', () {
+ test('send called on send', () async {
+ final sut = fixture.getSut();
+
+ final request = Request('GET', Uri.parse('https://example.com/123'));
+
+ await sut.send(request);
+
+ expect(fixture.mockClient.sendCalls.length, 1);
+ expect(fixture.mockClient.sendCalls.first, request);
+ });
+
+ test('close called on close', () async {
+ final sut = fixture.getSut();
+
+ sut.close();
+
+ expect(fixture.mockClient.closeCalls.length, 1);
+ });
+ });
+
+ group('Breadcrumb', () {
+ test('added on select', () async {
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('countries').select().eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.addBreadcrumbCalls.length, 1);
+ final breadcrumb = fixture.mockHub.addBreadcrumbCalls.first.$1;
+ expect(breadcrumb.message, 'from(countries)');
+ expect(breadcrumb.category, 'db.select');
+ expect(breadcrumb.type, 'supabase');
+
+ expect(breadcrumb.data?['table'], 'countries');
+ expect(breadcrumb.data?['operation'], 'select');
+ expect(breadcrumb.data?['query'], ['select(*)', 'eq(id, 42)']);
+ });
+
+ test('added on insert', () async {
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('countries').insert({'id': 42});
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.addBreadcrumbCalls.length, 1);
+ final breadcrumb = fixture.mockHub.addBreadcrumbCalls.first.$1;
+ expect(breadcrumb.message, 'from(countries)');
+ expect(breadcrumb.category, 'db.insert');
+ expect(breadcrumb.type, 'supabase');
+
+ expect(breadcrumb.data?['table'], 'countries');
+ expect(breadcrumb.data?['operation'], 'insert');
+ expect(breadcrumb.data?['body'], {'id': 42});
+ });
+
+ test('added on upsert', () async {
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('countries').upsert({'id': 42}).select();
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.addBreadcrumbCalls.length, 1);
+ final breadcrumb = fixture.mockHub.addBreadcrumbCalls.first.$1;
+ expect(breadcrumb.message, 'from(countries)');
+ expect(breadcrumb.category, 'db.upsert');
+ expect(breadcrumb.type, 'supabase');
+
+ expect(breadcrumb.data?['table'], 'countries');
+ expect(breadcrumb.data?['operation'], 'upsert');
+ expect(breadcrumb.data?['query'], ['select(*)']);
+ expect(breadcrumb.data?['body'], {'id': 42});
+ });
+
+ test('added on update', () async {
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('countries').update({'id': 1337}).eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.addBreadcrumbCalls.length, 1);
+ final breadcrumb = fixture.mockHub.addBreadcrumbCalls.first.$1;
+ expect(breadcrumb.message, 'from(countries)');
+ expect(breadcrumb.category, 'db.update');
+ expect(breadcrumb.type, 'supabase');
+
+ expect(breadcrumb.data?['table'], 'countries');
+ expect(breadcrumb.data?['operation'], 'update');
+ expect(breadcrumb.data?['query'], ['eq(id, 42)']);
+ expect(breadcrumb.data?['body'], {'id': 1337});
+ });
+
+ test('added on delete', () async {
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('countries').delete().eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.addBreadcrumbCalls.length, 1);
+ final breadcrumb = fixture.mockHub.addBreadcrumbCalls.first.$1;
+ expect(breadcrumb.message, 'from(countries)');
+ expect(breadcrumb.category, 'db.delete');
+ expect(breadcrumb.type, 'supabase');
+
+ expect(breadcrumb.data?['table'], 'countries');
+ expect(breadcrumb.data?['operation'], 'delete');
+ expect(breadcrumb.data?['query'], ['eq(id, 42)']);
+ });
+ });
+
+ group('PII', () {
+ test('defaultPii disabled does not send body', () async {
+ fixture.options.sendDefaultPii = false;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('countries').insert({'id': 42});
+ } catch (e) {
+ // Ignore
+ }
+ try {
+ await supabase.from('countries').upsert({'id': 42}).select();
+ } catch (e) {
+ // Ignore
+ }
+ try {
+ await supabase.from('countries').update({'id': 1337}).eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ final insertBreadcrumb = fixture.mockHub.addBreadcrumbCalls.first.$1;
+ expect(insertBreadcrumb.data?['query'], isNull);
+ expect(insertBreadcrumb.data?['body'], isNull);
+
+ final upsertBreadcrumb = fixture.mockHub.addBreadcrumbCalls[1].$1;
+ expect(upsertBreadcrumb.data?['query'], isNull);
+ expect(upsertBreadcrumb.data?['body'], isNull);
+
+ final updateBreadcrumb = fixture.mockHub.addBreadcrumbCalls[2].$1;
+ expect(updateBreadcrumb.data?['query'], isNull);
+ expect(updateBreadcrumb.data?['body'], isNull);
+ });
+ });
+}
+
+class Fixture {
+ final supabaseUrl = 'https://example.com';
+ final supabaseKey = 'YOUR_ANON_KEY';
+
+ final options = SentryOptions(
+ dsn: 'https://example.com/123',
+ );
+ final mockClient = _MockClient();
+ late final mockHub = MockHub(options);
+
+ Fixture() {
+ options.sendDefaultPii = true; // Send PII by default in test.
+ }
+
+ SentrySupabaseBreadcrumbClient getSut() {
+ return SentrySupabaseBreadcrumbClient(
+ mockClient,
+ mockHub,
+ );
+ }
+
+ SupabaseClient getSupabaseClient() {
+ return SupabaseClient(
+ supabaseUrl,
+ supabaseKey,
+ httpClient: getSut(),
+ );
+ }
+}
+
+class _MockClient extends BaseClient {
+ final sendCalls = [];
+ final closeCalls = [];
+
+ var jsonResponse = '{}';
+ var statusCode = 200;
+
+ @override
+ Future send(BaseRequest request) async {
+ sendCalls.add(request);
+ return StreamedResponse(
+ Stream.value(utf8.encode(jsonResponse)),
+ statusCode,
+ );
+ }
+
+ @override
+ void close() {
+ closeCalls.add(null);
+ }
+}
diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart
new file mode 100644
index 0000000000..a7f755a27a
--- /dev/null
+++ b/supabase/test/sentry_supabase_client_test.dart
@@ -0,0 +1,132 @@
+import 'package:sentry_supabase/sentry_supabase.dart';
+import 'package:test/test.dart';
+import 'package:sentry/sentry.dart';
+import 'package:http/http.dart';
+
+import 'mocks/mock_client.dart';
+import 'mocks/mock_hub.dart';
+
+void main() {
+ late Fixture fixture;
+
+ setUp(() {
+ fixture = Fixture();
+ });
+
+ group('Inner Client', () {
+ test('send called on send', () async {
+ final sut = fixture.getSut(
+ enableBreadcrumbs: true,
+ enableTracing: true,
+ enableErrors: true,
+ );
+
+ final request = Request('GET', Uri.parse('https://example.com/123'));
+
+ await sut.send(request);
+
+ expect(fixture.mockClient.sendCalls.length, 1);
+ expect(fixture.mockClient.sendCalls.first, request);
+ });
+
+ test('close called on close', () async {
+ final sut = fixture.getSut(
+ enableBreadcrumbs: true,
+ enableTracing: true,
+ enableErrors: true,
+ );
+
+ sut.close();
+
+ expect(fixture.mockClient.closeCalls.length, 1);
+ });
+ });
+
+ group('Inner Sentry Supabase Clients', () {
+ test('breadcrumb client', () async {
+ final sut = fixture.getSut(
+ enableBreadcrumbs: true,
+ enableTracing: false,
+ enableErrors: false,
+ );
+
+ final request =
+ Request('GET', Uri.parse('https://example.com/rest/v1/users'));
+ await sut.send(request);
+
+ expect(fixture.mockHub.addBreadcrumbCalls.length, 1);
+ });
+
+ test('tracing client', () async {
+ final sut = fixture.getSut(
+ enableBreadcrumbs: false,
+ enableTracing: true,
+ enableErrors: false,
+ );
+
+ final request =
+ Request('GET', Uri.parse('https://example.com/rest/v1/users'));
+ await sut.send(request);
+
+ expect(fixture.mockHub.getSpanCallCount, 1);
+ });
+
+ test('error client', () async {
+ final sut = fixture.getSut(
+ enableBreadcrumbs: false,
+ enableTracing: false,
+ enableErrors: true,
+ );
+
+ fixture.mockClient.statusCode = 404;
+
+ final request =
+ Request('GET', Uri.parse('https://example.com/rest/v1/users'));
+ await sut.send(request);
+
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ });
+
+ test('all clients', () async {
+ final sut = fixture.getSut(
+ enableBreadcrumbs: true,
+ enableTracing: true,
+ enableErrors: true,
+ );
+
+ fixture.mockClient.statusCode = 404;
+
+ final request =
+ Request('GET', Uri.parse('https://example.com/rest/v1/users'));
+ await sut.send(request);
+
+ expect(fixture.mockHub.addBreadcrumbCalls.length, 1);
+ expect(fixture.mockHub.getSpanCallCount, 1);
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ });
+ });
+}
+
+class Fixture {
+ final supabaseUrl = 'https://example.com';
+
+ final options = SentryOptions(
+ dsn: 'https://example.com/123',
+ );
+ final mockClient = MockClient();
+ late final mockHub = MockHub(options);
+
+ SentrySupabaseClient getSut({
+ required bool enableBreadcrumbs,
+ required bool enableTracing,
+ required bool enableErrors,
+ }) {
+ return SentrySupabaseClient(
+ enableBreadcrumbs: enableBreadcrumbs,
+ enableTracing: enableTracing,
+ enableErrors: enableErrors,
+ client: mockClient,
+ hub: mockHub,
+ );
+ }
+}
diff --git a/supabase/test/sentry_supabase_error_client_test.dart b/supabase/test/sentry_supabase_error_client_test.dart
new file mode 100644
index 0000000000..9303ef456a
--- /dev/null
+++ b/supabase/test/sentry_supabase_error_client_test.dart
@@ -0,0 +1,279 @@
+import 'package:sentry_supabase/src/sentry_supabase_error_client.dart';
+import 'package:sentry_supabase/src/sentry_supabase_client_error.dart';
+import 'package:test/test.dart';
+import 'package:sentry/sentry.dart';
+import 'package:http/http.dart';
+
+import 'package:supabase/supabase.dart';
+import 'mocks/mock_client.dart';
+import 'mocks/mock_hub.dart';
+
+void main() {
+ late Fixture fixture;
+
+ setUp(() {
+ fixture = Fixture();
+ });
+
+ group('Inner Client', () {
+ test('send called on send', () async {
+ final sut = fixture.getSut();
+
+ final request = Request('GET', Uri.parse('https://example.com/123'));
+
+ await sut.send(request);
+
+ expect(fixture.mockClient.sendCalls.length, 1);
+ expect(fixture.mockClient.sendCalls.first, request);
+ });
+
+ test('close called on close', () async {
+ final sut = fixture.getSut();
+
+ sut.close();
+
+ expect(fixture.mockClient.closeCalls.length, 1);
+ });
+ });
+
+ group('Error', () {
+ test('should create error if select request fails', () async {
+ fixture.mockClient.statusCode = 404;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').select().eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ final event = fixture.mockHub.captureEventCalls.first.$1;
+
+ expect(event.throwableMechanism, isA());
+ final throwableMechanism = event.throwableMechanism as ThrowableMechanism;
+
+ expect(throwableMechanism.mechanism.type, 'SentrySupabaseClient');
+ expect(throwableMechanism.throwable, isA());
+
+ final error = throwableMechanism.throwable as SentrySupabaseClientError;
+ expect(error.toString().contains('404'), true);
+ });
+
+ test('should capture error if send throws', () async {
+ final error = Exception('test');
+ fixture.mockClient.throwException = error;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').select().eq('id', 42);
+ } catch (e) {
+ expect(e, error); // Error is rethrown
+ }
+
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ final event = fixture.mockHub.captureEventCalls.first.$1;
+
+ expect(event.throwableMechanism, isA());
+ final throwableMechanism = event.throwableMechanism as ThrowableMechanism;
+
+ expect(throwableMechanism.mechanism.type, 'SentrySupabaseClient');
+ expect(throwableMechanism.throwable, error);
+ });
+ });
+
+ group('Supabase Context', () {
+ test('should add supabase data to context if select request fails',
+ () async {
+ fixture.mockClient.statusCode = 404;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').select().eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ final event = fixture.mockHub.captureEventCalls.first.$1;
+
+ expect(event.contexts['supabase'], isNotNull);
+ final supabaseContext =
+ event.contexts['supabase'] as Map;
+ expect(supabaseContext['table'], 'mock-table');
+ expect(supabaseContext['operation'], 'select');
+ expect(supabaseContext['query'], ['select(*)', 'eq(id, 42)']);
+ });
+
+ test('should add supabase data to context if insert request fails',
+ () async {
+ fixture.mockClient.statusCode = 404;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').insert({'id': 42});
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ final event = fixture.mockHub.captureEventCalls.first.$1;
+
+ expect(event.contexts['supabase'], isNotNull);
+ final supabaseContext =
+ event.contexts['supabase'] as Map;
+ expect(supabaseContext['table'], 'mock-table');
+ expect(supabaseContext['operation'], 'insert');
+ expect(supabaseContext['body'], {'id': 42});
+ });
+
+ test('should add supabase data to context if update request fails',
+ () async {
+ fixture.mockClient.statusCode = 404;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').update({'id': 1337}).eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ final event = fixture.mockHub.captureEventCalls.first.$1;
+
+ expect(event.contexts['supabase'], isNotNull);
+ final supabaseContext =
+ event.contexts['supabase'] as Map;
+ expect(supabaseContext['table'], 'mock-table');
+ expect(supabaseContext['operation'], 'update');
+ expect(supabaseContext['body'], {'id': 1337});
+ expect(supabaseContext['query'], ['eq(id, 42)']);
+ });
+
+ test('should add supabase data to context if upsert request fails',
+ () async {
+ fixture.mockClient.statusCode = 404;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').upsert({'id': 42}).select();
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ final event = fixture.mockHub.captureEventCalls.first.$1;
+
+ expect(event.contexts['supabase'], isNotNull);
+ final supabaseContext =
+ event.contexts['supabase'] as Map;
+ expect(supabaseContext['table'], 'mock-table');
+ expect(supabaseContext['operation'], 'upsert');
+ expect(supabaseContext['body'], {'id': 42});
+ expect(supabaseContext['query'], ['select(*)']);
+ });
+
+ test('should add supabase data to context if delete request fails',
+ () async {
+ fixture.mockClient.statusCode = 404;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').delete().eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ expect(fixture.mockHub.captureEventCalls.length, 1);
+ final event = fixture.mockHub.captureEventCalls.first.$1;
+
+ expect(event.contexts['supabase'], isNotNull);
+ final supabaseContext =
+ event.contexts['supabase'] as Map;
+ expect(supabaseContext['table'], 'mock-table');
+ expect(supabaseContext['operation'], 'delete');
+ expect(supabaseContext['query'], ['eq(id, 42)']);
+ });
+ });
+
+ group('PII', () {
+ test('defaultPii disabled does not send body', () async {
+ fixture.mockClient.statusCode = 404;
+ fixture.options.sendDefaultPii = false;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('countries').insert({'id': 42});
+ } catch (e) {
+ // Ignore
+ }
+
+ try {
+ await supabase.from('countries').upsert({'id': 42}).select();
+ } catch (e) {
+ // Ignore
+ }
+
+ try {
+ await supabase.from('countries').update({'id': 1337}).eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ final insertEvent = fixture.mockHub.captureEventCalls[0].$1;
+ final insertSupabaseContext =
+ insertEvent.contexts['supabase'] as Map;
+ expect(insertSupabaseContext['query'], isNull);
+ expect(insertSupabaseContext['body'], isNull);
+
+ final upsertEvent = fixture.mockHub.captureEventCalls[1].$1;
+ final upsertSupabaseContext =
+ upsertEvent.contexts['supabase'] as Map;
+ expect(upsertSupabaseContext['query'], isNull);
+ expect(upsertSupabaseContext['body'], isNull);
+
+ final updateEvent = fixture.mockHub.captureEventCalls[2].$1;
+ final updateSupabaseContext =
+ updateEvent.contexts['supabase'] as Map;
+ expect(updateSupabaseContext['query'], isNull);
+ expect(updateSupabaseContext['body'], isNull);
+ });
+ });
+}
+
+class Fixture {
+ final supabaseUrl = 'https://example.com';
+
+ final options = SentryOptions(
+ dsn: 'https://example.com/123',
+ );
+ final mockClient = MockClient();
+ late final mockHub = MockHub(options);
+
+ Fixture() {
+ options.sendDefaultPii = true; // Send PII by default in test.
+ }
+
+ SentrySupabaseErrorClient getSut() {
+ return SentrySupabaseErrorClient(
+ mockClient,
+ mockHub,
+ );
+ }
+
+ SupabaseClient getSupabaseClient() {
+ return SupabaseClient(
+ supabaseUrl,
+ 'YOUR_ANON_KEY',
+ httpClient: getSut(),
+ );
+ }
+}
diff --git a/supabase/test/sentry_supabase_request_test.dart b/supabase/test/sentry_supabase_request_test.dart
new file mode 100644
index 0000000000..f2848d6087
--- /dev/null
+++ b/supabase/test/sentry_supabase_request_test.dart
@@ -0,0 +1,543 @@
+import 'package:http/http.dart';
+import 'package:sentry_supabase/src/sentry_supabase_request.dart';
+import 'package:test/test.dart';
+import 'dart:convert';
+
+void main() {
+ group('SentrySupabaseRequest', () {
+ group('only consider "rest/v1" as the base path', () {
+ test('ignores non-rest/v1 paths', () {
+ final request =
+ Request('GET', Uri.parse('https://example.com/foo/v1/users'));
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request);
+ expect(supabaseRequest, isNull);
+ });
+ });
+ group('generateSqlQuery', () {
+ group('SELECT operations', () {
+ test('basic SELECT', () {
+ final request =
+ Request('GET', Uri.parse('https://example.com/rest/v1/users'));
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'SELECT * FROM "users"',
+ );
+ });
+ });
+
+ group('INSERT operations', () {
+ test('INSERT with body data', () {
+ final request = Request(
+ 'POST',
+ Uri.parse('https://example.com/rest/v1/users'),
+ )..body = jsonEncode({'name': 'John', 'email': 'john@example.com'});
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'INSERT INTO "users" ("name", "email") VALUES (?, ?)',
+ );
+ });
+
+ test('INSERT with single column', () {
+ final request =
+ Request('POST', Uri.parse('https://example.com/rest/v1/users'))
+ ..body = jsonEncode({'id': 42});
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'INSERT INTO "users" ("id") VALUES (?)',
+ );
+ });
+ });
+
+ group('UPSERT operations', () {
+ test('UPSERT with body data', () {
+ final request = Request(
+ 'POST',
+ Uri.parse('https://example.com/rest/v1/users'),
+ )..body = jsonEncode({'name': 'John', 'email': 'john@example.com'});
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'INSERT INTO "users" ("name", "email") VALUES (?, ?)',
+ );
+ });
+ });
+
+ group('UPDATE operations', () {
+ test('UPDATE with body and WHERE clause', () {
+ final request = Request(
+ 'PATCH',
+ Uri.parse('https://example.com/rest/v1/users?id=eq.42'),
+ )..body = jsonEncode({'name': 'Jane', 'email': 'jane@example.com'});
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'UPDATE "users" SET "name" = ?, "email" = ? WHERE id = ?',
+ );
+ });
+
+ test('UPDATE with single column', () {
+ final request = Request(
+ 'PATCH',
+ Uri.parse('https://example.com/rest/v1/users?id=eq.42'),
+ )..body = jsonEncode({'status': 'active'});
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'UPDATE "users" SET "status" = ? WHERE id = ?',
+ );
+ });
+
+ test('UPDATE without WHERE clause', () {
+ final request =
+ Request('PATCH', Uri.parse('https://example.com/rest/v1/users'))
+ ..body = jsonEncode({'status': 'inactive'});
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'UPDATE "users" SET "status" = ?',
+ );
+ });
+ });
+
+ group('DELETE operations', () {
+ test('DELETE with WHERE clause', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?id=eq.42'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE id = ?',
+ );
+ });
+
+ test('DELETE without WHERE clause', () {
+ final request =
+ Request('DELETE', Uri.parse('https://example.com/rest/v1/users'));
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users"',
+ );
+ });
+ });
+
+ group('UNKNOWN operations', () {
+ test('unsupported HTTP method', () {
+ final request = Request(
+ 'OPTIONS',
+ Uri.parse('https://example.com/rest/v1/users'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'UNKNOWN OPERATION ON "users"',
+ );
+ });
+ });
+ });
+
+ group('WHERE clause generation', () {
+ group('equality operators', () {
+ test('eq (equals)', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?id=eq.42'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE id = ?',
+ );
+ });
+
+ test('neq (not equals)', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?status=neq.inactive',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE status != ?',
+ );
+ });
+ });
+
+ group('comparison operators', () {
+ test('gt (greater than)', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?age=gt.18'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE age > ?',
+ );
+ });
+
+ test('gte (greater than or equal)', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?age=gte.21'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE age >= ?',
+ );
+ });
+
+ test('lt (less than)', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?age=lt.65'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE age < ?',
+ );
+ });
+
+ test('lte (less than or equal)', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?age=lte.64'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE age <= ?',
+ );
+ });
+ });
+
+ group('pattern matching operators', () {
+ test('like with wildcards', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?name=like.*john*'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE name LIKE ?',
+ );
+ });
+
+ test('ilike (case insensitive)', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?name=ilike.John'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE name ILIKE ?',
+ );
+ });
+ });
+
+ group('array operators', () {
+ test('in with quoted values', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?status=in.("active","pending")',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE status IN ?',
+ );
+ });
+
+ test('in with unquoted values', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?status=in.(active,pending)',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE status IN ?',
+ );
+ });
+ });
+
+ group('complex WHERE clauses', () {
+ test('multiple AND conditions', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?id=eq.42&status=eq.active&age=gt.18',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE id = ? AND status = ? AND age > ?',
+ );
+ });
+
+ test('OR condition', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?id=eq.42&or=status.eq.inactive',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE id = ? OR status = ?',
+ );
+ });
+
+ test('multiple OR conditions', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?id=eq.42&or=status.eq.inactive&or=age.lt.18',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE id = ? OR status = ? OR age < ?',
+ );
+ });
+
+ test('NOT condition', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?not=status.eq.deleted',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE status != ?',
+ );
+ });
+
+ test('mixed AND, OR, and NOT conditions', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?id=eq.42&age=gt.18&or=status.eq.premium¬=type.eq.bot',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE id = ? AND age > ? OR status = ? AND type != ?',
+ );
+ });
+ });
+
+ group('SELECT with WHERE clauses', () {
+ test('SELECT ignores WHERE clauses in SQL generation', () {
+ final request = Request(
+ 'GET',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?id=eq.42&status=eq.active',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'SELECT * FROM "users"',
+ );
+ });
+ });
+
+ group('edge cases', () {
+ test('malformed query parameter', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?invalid_param'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users"',
+ );
+ });
+
+ test('empty query value', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?id='),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users"',
+ );
+ });
+
+ test('query with select that should be skipped in WHERE', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?select=name,email&id=eq.42',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE id = ?',
+ );
+ });
+
+ test('unknown operator defaults to equals', () {
+ final request = Request(
+ 'DELETE',
+ Uri.parse('https://example.com/rest/v1/users?id=unknown.42'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.generateSqlQuery(),
+ 'DELETE FROM "users" WHERE id = ?',
+ );
+ });
+ });
+ });
+
+ group('query parsing', () {
+ test('parses table name from URL path', () {
+ final request = Request(
+ 'GET',
+ Uri.parse('https://example.com/rest/v1/my_table_name'),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(supabaseRequest.table, 'my_table_name');
+ });
+
+ test('parses operation from HTTP method and headers', () {
+ // GET -> SELECT
+ var request =
+ Request('GET', Uri.parse('https://example.com/rest/v1/users'));
+ var supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+ expect(supabaseRequest.operation.value, 'select');
+
+ // POST -> INSERT
+ request =
+ Request('POST', Uri.parse('https://example.com/rest/v1/users'));
+ supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+ expect(supabaseRequest.operation.value, 'insert');
+
+ // POST with Prefer header -> UPSERT
+ request =
+ Request('POST', Uri.parse('https://example.com/rest/v1/users'))
+ ..headers['Prefer'] = 'resolution=merge-duplicates';
+ supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+ expect(supabaseRequest.operation.value, 'upsert');
+
+ // PATCH -> UPDATE
+ request =
+ Request('PATCH', Uri.parse('https://example.com/rest/v1/users'));
+ supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+ expect(supabaseRequest.operation.value, 'update');
+
+ // DELETE -> DELETE
+ request =
+ Request('DELETE', Uri.parse('https://example.com/rest/v1/users'));
+ supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+ expect(supabaseRequest.operation.value, 'delete');
+ });
+
+ test('parses query parameters into query list', () {
+ final request = Request(
+ 'GET',
+ Uri.parse(
+ 'https://example.com/rest/v1/users?id=eq.42&name=ilike.John&status=in.("active","pending")&select=id,name',
+ ),
+ );
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(
+ supabaseRequest.query,
+ containsAll([
+ 'eq(id, 42)',
+ 'ilike(name, John)',
+ 'in(status, ("active","pending"))',
+ 'select(id,name)',
+ ]),
+ );
+ });
+
+ test('parses JSON body', () {
+ final request =
+ Request('POST', Uri.parse('https://example.com/rest/v1/users'))
+ ..body = jsonEncode({'name': 'John', 'age': 30});
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(supabaseRequest.body, {'name': 'John', 'age': 30});
+ });
+
+ test('handles non-JSON body gracefully', () {
+ final request =
+ Request('POST', Uri.parse('https://example.com/rest/v1/users'))
+ ..body = 'not valid json';
+
+ expect(
+ () => SentrySupabaseRequest.fromRequest(request),
+ throwsA(isA()),
+ );
+ });
+
+ test('handles empty body', () {
+ final request =
+ Request('POST', Uri.parse('https://example.com/rest/v1/users'));
+ final supabaseRequest = SentrySupabaseRequest.fromRequest(request)!;
+
+ expect(supabaseRequest.body, isNull);
+ });
+ });
+ });
+}
diff --git a/supabase/test/sentry_supabase_tracing_client_test.dart b/supabase/test/sentry_supabase_tracing_client_test.dart
new file mode 100644
index 0000000000..2bccb603bb
--- /dev/null
+++ b/supabase/test/sentry_supabase_tracing_client_test.dart
@@ -0,0 +1,315 @@
+import 'package:sentry_supabase/src/sentry_supabase_tracing_client.dart';
+import 'package:test/test.dart';
+import 'package:sentry/sentry.dart';
+import 'package:http/http.dart';
+
+import 'package:supabase/supabase.dart';
+import 'mocks/mock_client.dart';
+import 'mocks/mock_hub.dart';
+
+void main() {
+ late Fixture fixture;
+
+ setUp(() {
+ fixture = Fixture();
+ });
+
+ group('Inner Client', () {
+ test('send called on send', () async {
+ final sut = fixture.getSut();
+
+ final request = Request('GET', Uri.parse('https://example.com/123'));
+
+ await sut.send(request);
+
+ expect(fixture.mockClient.sendCalls.length, 1);
+ expect(fixture.mockClient.sendCalls.first, request);
+ });
+
+ test('close called on close', () async {
+ final sut = fixture.getSut();
+
+ sut.close();
+
+ expect(fixture.mockClient.closeCalls.length, 1);
+ });
+
+ test('should not create span for auth requests', () async {
+ final sut = fixture.getSut();
+
+ final request = Request('GET',
+ Uri.parse('https://example.com/auth/v1/token?grant_type=password'));
+
+ await sut.send(request);
+
+ expect(fixture.mockHub.getSpanCallCount, 0);
+ });
+ });
+
+ group('Tracing', () {
+ void verifySpanCreation(String operation) {
+ expect(fixture.mockHub.getSpanCallCount, 1);
+ expect(fixture.mockHub.currentSpan.startChildCalls.length, 1);
+ final startChildCall = fixture.mockHub.currentSpan.startChildCalls.first;
+ expect(startChildCall.$1, 'db.$operation'); // operation
+ expect(startChildCall.$2, 'from(mock-table)'); // description
+ }
+
+ void verifyCommonSpanAttributes(String version) {
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.data['db.schema'], 'public');
+ expect(span.data['db.table'], 'mock-table');
+ expect(span.data['db.url'], 'https://example.com');
+ expect(span.data['db.sdk'], version);
+ expect(span.data['db.system'], 'postgres');
+ // ignore: invalid_use_of_internal_member
+ expect(span.data['origin'], SentryTraceOrigins.autoDbSupabase);
+ }
+
+ void verifyFinishSpan() {
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.finishCalls.length, 1);
+ final setStatusCall = span.setStatusCalls.first;
+ expect(setStatusCall, SpanStatus.ok());
+ }
+
+ test('should create trace for select', () async {
+ fixture.mockClient.jsonResponse = '{"id": 42}';
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase
+ .from('mock-table')
+ .select()
+ .lt('id', 42)
+ .gt('id', 20)
+ .not('id', 'eq', 32)
+ .ilike('name', 'John')
+ .inFilter('status', ['active', 'pending']);
+ } catch (e) {
+ // Ignore
+ }
+
+ verifySpanCreation('select');
+ verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? '');
+ verifyFinishSpan();
+
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.data['db.query'], [
+ 'select(*)',
+ 'lt(id, 42)',
+ 'gt(id, 20)',
+ 'not(id, eq.32)',
+ 'ilike(name, John)',
+ 'in(status, ("active","pending"))',
+ ]);
+ expect(span.data['db.operation'], 'select');
+ expect(span.data['db.sql.query'], 'SELECT * FROM "mock-table"');
+ });
+
+ test('should create trace for insert', () async {
+ fixture.mockClient.jsonResponse = '{"id": 42}';
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').insert({'id': 42});
+ } catch (e) {
+ // Ignore
+ }
+
+ verifySpanCreation('insert');
+ verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? '');
+ verifyFinishSpan();
+
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.data['db.body'], {'id': 42});
+ expect(span.data['db.operation'], 'insert');
+ expect(
+ span.data['db.sql.query'],
+ 'INSERT INTO "mock-table" ("id") VALUES (?)',
+ );
+ });
+
+ test('should create trace for upsert', () async {
+ fixture.mockClient.jsonResponse = '{"id": 42}';
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').upsert({'id': 42}).select('id,name');
+ } catch (e) {
+ // Ignore
+ }
+
+ verifySpanCreation('upsert');
+ verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? '');
+ verifyFinishSpan();
+
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.data['db.body'], {'id': 42});
+ expect(span.data['db.query'], ['select(id,name)']);
+ expect(span.data['db.operation'], 'upsert');
+ expect(
+ span.data['db.sql.query'],
+ 'INSERT INTO "mock-table" ("id") VALUES (?)',
+ );
+ });
+
+ test('should create trace for update', () async {
+ fixture.mockClient.jsonResponse = '{"id": 1337}';
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase
+ .from('mock-table')
+ .update({'id': 1337})
+ .eq('id', 42)
+ .or('id.eq.8');
+ } catch (e) {
+ // Ignore
+ }
+
+ verifySpanCreation('update');
+ verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? '');
+ verifyFinishSpan();
+
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.data['db.body'], {'id': 1337});
+ expect(span.data['db.query'], ['eq(id, 42)', 'or(id.eq.8)']);
+ expect(span.data['db.operation'], 'update');
+ expect(
+ span.data['db.sql.query'],
+ 'UPDATE "mock-table" SET "id" = ? WHERE id = ? OR id = ?',
+ );
+ });
+
+ test('should create trace for delete', () async {
+ fixture.mockClient.jsonResponse = '{}';
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').delete().eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ verifySpanCreation('delete');
+ verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? '');
+ verifyFinishSpan();
+
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.data['db.query'], ['eq(id, 42)']);
+ expect(span.data['db.operation'], 'delete');
+ expect(
+ span.data['db.sql.query'],
+ 'DELETE FROM "mock-table" WHERE id = ?',
+ );
+ });
+
+ test('should finish with error status if request fails', () async {
+ fixture.mockClient.statusCode = 404;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').delete().eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.finishCalls.length, 1);
+ final setStatusCall = span.setStatusCalls.first;
+ expect(setStatusCall, SpanStatus.fromHttpStatusCode(404));
+ });
+
+ test(
+ 'should finish with exception and internal error status if request throws',
+ () async {
+ final exception = Exception('test');
+ fixture.mockClient.throwException = exception;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('mock-table').delete().eq('id', 42);
+ } catch (e) {
+ expect(e, exception); // Rethrows
+ }
+
+ final span = fixture.mockHub.currentSpan.childSpan;
+ expect(span.finishCalls.length, 1);
+
+ final setThrowableCall = span.setThrowableCalls.first;
+ expect(setThrowableCall, exception);
+
+ final setStatusCall = span.setStatusCalls.first;
+ expect(setStatusCall, SpanStatus.internalError());
+ });
+ });
+
+ group('PII', () {
+ test('defaultPii disabled does not send body', () async {
+ fixture.options.sendDefaultPii = false;
+
+ final supabase = fixture.getSupabaseClient();
+
+ try {
+ await supabase.from('countries').insert({'id': 42});
+ } catch (e) {
+ // Ignore
+ }
+ final insertSpan = fixture.mockHub.currentSpan.childSpan;
+ expect(insertSpan.data['db.query'], isNull);
+ expect(insertSpan.data['db.body'], isNull);
+
+ try {
+ await supabase.from('countries').upsert({'id': 42}).select();
+ } catch (e) {
+ // Ignore
+ }
+ final upsertSpan = fixture.mockHub.currentSpan.childSpan;
+ expect(upsertSpan.data['db.body'], isNull);
+ expect(upsertSpan.data['db.query'], isNull);
+ try {
+ await supabase.from('countries').update({'id': 1337}).eq('id', 42);
+ } catch (e) {
+ // Ignore
+ }
+ final updateSpan = fixture.mockHub.currentSpan.childSpan;
+ expect(updateSpan.data['db.body'], isNull);
+ expect(updateSpan.data['db.query'], isNull);
+ });
+ });
+}
+
+class Fixture {
+ final supabaseUrl = 'https://example.com';
+ final supabaseKey = 'YOUR_ANON_KEY';
+
+ final options = SentryOptions(
+ dsn: 'https://example.com/123',
+ );
+ final mockClient = MockClient();
+ late final mockHub = MockHub(options);
+
+ Fixture() {
+ options.sendDefaultPii = true; // Send PII by default in test.
+ }
+
+ SentrySupabaseTracingClient getSut() {
+ return SentrySupabaseTracingClient(mockClient, mockHub);
+ }
+
+ SupabaseClient getSupabaseClient() {
+ return SupabaseClient(
+ supabaseUrl,
+ supabaseKey,
+ httpClient: getSut(),
+ );
+ }
+}