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 | [![build](https://github.com/getsentry/sentry-dart/actions/workflows/supabase.yml/badge.svg?branch=main)](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-supabase) | [![pub package](https://img.shields.io/pub/v/sentry_supabase.svg)](https://pub.dev/packages/sentry_supabase) | [![likes](https://img.shields.io/pub/likes/sentry_supabase)](https://pub.dev/packages/sentry_supabase/score) | [![popularity](https://img.shields.io/pub/popularity/sentry_supabase)](https://pub.dev/packages/sentry_supabase/score) | [![pub points](https://img.shields.io/pub/points/sentry_supabase)](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 + +* [![Flutter docs](https://img.shields.io/badge/documentation-sentry.io-green.svg?label=flutter%20docs)](https://docs.sentry.io/platforms/flutter/) +* [![Dart docs](https://img.shields.io/badge/documentation-sentry.io-green.svg?label=dart%20docs)](https://docs.sentry.io/platforms/dart/) +* [![Discussions](https://img.shields.io/github/discussions/getsentry/sentry-dart.svg)](https://github.com/getsentry/sentry-dart/discussions) +* [![Discord Chat](https://img.shields.io/discord/621778831602221064?logo=discord&logoColor=ffffff&color=7389D8)](https://discord.gg/PXa5Apfe7K) +* [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](https://stackoverflow.com/questions/tagged/sentry) +* [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](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(), + ); + } +}