From a8cee186430a37b1cdcf746fb7d8826e48f9cc3c Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 22 Apr 2025 17:39:10 +0200 Subject: [PATCH 01/32] add sentry_supabase package --- supabase/.gitignore | 14 ++++++ supabase/CHANGELOG.md | 1 + supabase/LICENSE | 21 +++++++++ supabase/README.md | 48 ++++++++++++++++++++ supabase/analysis_options.yaml | 30 ++++++++++++ supabase/dartdoc_options.yaml | 1 + supabase/example/supabase_example.dart | 6 +++ supabase/lib/sentry_supabase.dart | 8 ++++ supabase/lib/src/sentry_supabase_client.dart | 17 +++++++ supabase/pubspec.yaml | 17 +++++++ supabase/pubspec_overrides.yaml | 3 ++ supabase/test/supabase_test.dart | 16 +++++++ 12 files changed, 182 insertions(+) create mode 100644 supabase/.gitignore create mode 120000 supabase/CHANGELOG.md create mode 100644 supabase/LICENSE create mode 100644 supabase/README.md create mode 100644 supabase/analysis_options.yaml create mode 120000 supabase/dartdoc_options.yaml create mode 100644 supabase/example/supabase_example.dart create mode 100644 supabase/lib/sentry_supabase.dart create mode 100644 supabase/lib/src/sentry_supabase_client.dart create mode 100644 supabase/pubspec.yaml create mode 100644 supabase/pubspec_overrides.yaml create mode 100644 supabase/test/supabase_test.dart 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..83db2c646d --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,48 @@ +

+ + + +
+

+ + +=========== + +

+ + + +
+

+ +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 + +``` + +#### 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..dee8927aaf --- /dev/null +++ b/supabase/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options 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..2ac3227c0d --- /dev/null +++ b/supabase/example/supabase_example.dart @@ -0,0 +1,6 @@ +import 'package:supabase/sentry_supabase.dart'; + +void main() { + final client = SentrySupabaseClient(); + // TODO: Add supabase instumentation sample +} diff --git a/supabase/lib/sentry_supabase.dart b/supabase/lib/sentry_supabase.dart new file mode 100644 index 0000000000..25efb745a7 --- /dev/null +++ b/supabase/lib/sentry_supabase.dart @@ -0,0 +1,8 @@ +/// Support for doing something awesome. +/// +/// More dartdocs go here. +library; + +export 'src/sentry_supabase.dart'; + +// TODO: Export any libraries intended for clients of this package. diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart new file mode 100644 index 0000000000..a15dedd0d7 --- /dev/null +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -0,0 +1,17 @@ +import 'package:http/http.dart'; + +class SentrySupabaseClient extends BaseClient { + late final Client innerClient; + + SentrySupabaseClient({Client? client}) { + innerClient = client ?? Client(); + } + + @override + Future send(BaseRequest request) { + + // TODO: Instrument the supabase request + + return innerClient.send(request); + } +} diff --git a/supabase/pubspec.yaml b/supabase/pubspec.yaml new file mode 100644 index 0000000000..66d493f0cb --- /dev/null +++ b/supabase/pubspec.yaml @@ -0,0 +1,17 @@ +name: sentry_supabase +description: "Sentry integration to use instument Supabase." +version: 9.0.0-alpha.2 +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 + +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/supabase_test.dart b/supabase/test/supabase_test.dart new file mode 100644 index 0000000000..1e2043c3d7 --- /dev/null +++ b/supabase/test/supabase_test.dart @@ -0,0 +1,16 @@ +import 'package:sentry_supabase/sentry_supabase.dart'; +import 'package:test/test.dart'; + +void main() { + group('A group of tests', () { + final client = SentrySupabaseClient(); + + setUp(() { + // Additional setup goes here. + }); + + test('Sample test', () { + expect(client, isNotNull); + }); + }); +} From 30c089b14554ccc2771854ca54ea507362481d9b Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 22 Apr 2025 17:51:08 +0200 Subject: [PATCH 02/32] add to flutter/example app --- flutter/example/lib/main.dart | 17 +++++++++++++++++ flutter/example/pubspec.yaml | 2 ++ flutter/example/pubspec_overrides.yaml | 2 ++ supabase/lib/sentry_supabase.dart | 7 +------ supabase/pubspec.yaml | 1 + 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index ba3a40b9c2..01cab9f47b 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -28,6 +28,8 @@ import 'auto_close_screen.dart'; import 'drift/connection/connection.dart'; import 'drift/database.dart'; import 'isar/user.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; +import 'package:sentry_supabase/sentry_supabase.dart'; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io const String exampleDsn = @@ -42,6 +44,21 @@ var _isIntegrationTest = false; final GlobalKey navigatorKey = GlobalKey(); Future main() async { + final sentrySupabaseClient = SentrySupabaseClient(); + + await supabase.Supabase.initialize( + url: '', + anonKey: '', + httpClient: sentrySupabaseClient, + ); + + final supabaseClient = supabase.Supabase.instance.client; + final issues = await supabaseClient + .from('issues') + .select(); + + print(issues); + await setupSentry( () => runApp( SentryWidget( diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index ec54958579..9813b14305 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: flutter: sdk: flutter + supabase_flutter: ^2.8.4 sentry: sentry_flutter: sentry_dio: @@ -20,6 +21,7 @@ dependencies: sentry_hive: sentry_drift: sentry_isar: + sentry_supabase: universal_platform: ^1.0.0 feedback: ^2.0.0 provider: ^6.0.0 diff --git a/flutter/example/pubspec_overrides.yaml b/flutter/example/pubspec_overrides.yaml index 8f3cdc6729..90368dc0e6 100644 --- a/flutter/example/pubspec_overrides.yaml +++ b/flutter/example/pubspec_overrides.yaml @@ -27,3 +27,5 @@ dependency_overrides: isar_generator: version: ^3.1.0 hosted: https://pub.isar-community.dev/ + sentry_supabase: + path: ../../supabase diff --git a/supabase/lib/sentry_supabase.dart b/supabase/lib/sentry_supabase.dart index 25efb745a7..eb796b2e8e 100644 --- a/supabase/lib/sentry_supabase.dart +++ b/supabase/lib/sentry_supabase.dart @@ -1,8 +1,3 @@ -/// Support for doing something awesome. -/// -/// More dartdocs go here. library; -export 'src/sentry_supabase.dart'; - -// TODO: Export any libraries intended for clients of this package. +export 'src/sentry_supabase_client.dart'; diff --git a/supabase/pubspec.yaml b/supabase/pubspec.yaml index 66d493f0cb..c1bf951208 100644 --- a/supabase/pubspec.yaml +++ b/supabase/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: http: ^1.3.0 + sentry: 9.0.0-alpha.2 dev_dependencies: supabase: ^2.6.0 From 34b09231ea4d798e691335dfc2a9355d731c0e8a Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 14:06:39 +0200 Subject: [PATCH 03/32] instument with breadcrumbs for basic operations --- flutter/example/pubspec.yaml | 2 +- supabase/lib/src/operation.dart | 10 + supabase/lib/src/sentry_supabase_client.dart | 66 ++++++- .../test/sentry_supabase_client_test.dart | 181 ++++++++++++++++++ supabase/test/supabase_test.dart | 16 -- 5 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 supabase/lib/src/operation.dart create mode 100644 supabase/test/sentry_supabase_client_test.dart delete mode 100644 supabase/test/supabase_test.dart diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index f3c793def7..7467a4d782 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -11,7 +11,7 @@ environment: dependencies: flutter: sdk: flutter - supabase_flutter: ^2.8.4 + supabase_flutter: ^2.9.0 sentry: sentry_flutter: sentry_dio: diff --git a/supabase/lib/src/operation.dart b/supabase/lib/src/operation.dart new file mode 100644 index 0000000000..40d0bd6957 --- /dev/null +++ b/supabase/lib/src/operation.dart @@ -0,0 +1,10 @@ +enum Operation { + select('select'), + insert('insert'), + upsert('upsert'), + update('update'), + delete('delete'); + + final String value; + const Operation(this.value); +} diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index a15dedd0d7..323c0fa42e 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -1,17 +1,67 @@ import 'package:http/http.dart'; +import 'operation.dart'; +import 'package:sentry/sentry.dart'; class SentrySupabaseClient extends BaseClient { - late final Client innerClient; - - SentrySupabaseClient({Client? client}) { - innerClient = client ?? Client(); - } - + final Client _client; + final Hub _hub; + + SentrySupabaseClient({Client? client, Hub? hub}) : + _client = client ?? Client(), + _hub = hub ?? HubAdapter(); + @override Future send(BaseRequest request) { + final url = request.url; + final method = request.method; + final headers = request.headers; + + final table = url.pathSegments.last; + final description = 'from($table)'; + final operation = extractOperation(method, headers); - // TODO: Instrument the supabase request + if (operation != null) { + _addBreadcrumb(description, operation: operation); + } + + return _client.send(request); + } + + void _addBreadcrumb(String description, {required Operation operation}) { + final breadcrumb = Breadcrumb( + message: description, + category: 'db.${operation.value}', + type: 'supabase', + ); + _hub.addBreadcrumb(breadcrumb); + } - return innerClient.send(request); + 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 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..265862b55d --- /dev/null +++ b/supabase/test/sentry_supabase_client_test.dart @@ -0,0 +1,181 @@ +import 'package:sentry_supabase/sentry_supabase.dart'; +import 'package:test/test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:http/http.dart'; +import 'dart:convert'; + +import 'dart:async'; + +import 'package:supabase/supabase.dart'; + +void main() { + const supabaseUrl = 'YOUR_SUPABASE_URL'; + const supabaseKey = 'YOUR_ANON_KEY'; + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('Client', () { + test('calls send on innser client', () async { + final sentrySupabaseClient = fixture.getSut(); + + final request = Request('GET', Uri.parse('https://example.com/123')); + + await sentrySupabaseClient.send(request); + + expect(fixture.mockClient.sendCalls.length, 1); + expect(fixture.mockClient.sendCalls.first, request); + }); + }); + + group('Breadcrumb', () { + test('select adds a breadcrumb', () async { + final sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase.from('countries').select(); + } catch (e) { + print(e); + } + + 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'); + }); + + test('insert adds a breadcrumb', () async { + final sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase.from('countries').insert({}); + } catch (e) { + print(e); + } + + 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'); + }); + + test('upsert adds a breadcrumb', () async { + final sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase.from('countries').upsert({}); + } catch (e) { + print(e); + } + + 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'); + }); + + test('update adds a breadcrumb', () async { + final sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase.from('countries').update({}); + } catch (e) { + print(e); + } + + 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'); + }); + + test('delete adds a breadcrumb', () async { + final sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase.from('countries').delete(); + } catch (e) { + print(e); + } + + 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'); + }); + }); +} + +class Fixture { + final options = SentryOptions( + dsn: 'https://example.com/123', + ); + final mockClient = MockClient(); + final mockHub = MockHub(); + + SentrySupabaseClient getSut() { + return SentrySupabaseClient( + client: mockClient, + hub: mockHub, + ); + } +} + +class MockClient extends BaseClient { + final sendCalls = []; + final closeCalls = []; + + @override + Future send(BaseRequest request) async { + sendCalls.add(request); + return StreamedResponse(Stream.value(utf8.encode('{}')), 200); + } +} + +class MockHub implements Hub { + final addBreadcrumbCalls = <(Breadcrumb, Hint?)>[]; + + @override + Future addBreadcrumb(Breadcrumb crumb, {Hint? hint}) async { + addBreadcrumbCalls.add((crumb, hint)); + } + + // No such method + @override + void noSuchMethod(Invocation invocation) { + 'Method ${invocation.memberName} was called ' + 'with arguments ${invocation.positionalArguments}'; + } +} diff --git a/supabase/test/supabase_test.dart b/supabase/test/supabase_test.dart deleted file mode 100644 index 1e2043c3d7..0000000000 --- a/supabase/test/supabase_test.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:sentry_supabase/sentry_supabase.dart'; -import 'package:test/test.dart'; - -void main() { - group('A group of tests', () { - final client = SentrySupabaseClient(); - - setUp(() { - // Additional setup goes here. - }); - - test('Sample test', () { - expect(client, isNotNull); - }); - }); -} From b10426552574eb28ad96137142ff1b3ac9e3cf94 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 14:09:58 +0200 Subject: [PATCH 04/32] fix typo --- supabase/test/sentry_supabase_client_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index 265862b55d..32124ce90a 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -18,7 +18,7 @@ void main() { }); group('Client', () { - test('calls send on innser client', () async { + test('calls send on inner client', () async { final sentrySupabaseClient = fixture.getSut(); final request = Request('GET', Uri.parse('https://example.com/123')); From 4459e95ee35a2c395dcf9b8debc26198de257f8d Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 14:15:23 +0200 Subject: [PATCH 05/32] only add breadcrumb when enabled --- supabase/lib/src/sentry_supabase_client.dart | 8 ++-- .../test/sentry_supabase_client_test.dart | 45 ++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 323c0fa42e..476459e2b6 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -3,10 +3,12 @@ import 'operation.dart'; import 'package:sentry/sentry.dart'; class SentrySupabaseClient extends BaseClient { + final bool _breadcrumbs; final Client _client; final Hub _hub; - SentrySupabaseClient({Client? client, Hub? hub}) : + SentrySupabaseClient({required bool breadcrumbs, Client? client, Hub? hub}) : + _breadcrumbs = breadcrumbs, _client = client ?? Client(), _hub = hub ?? HubAdapter(); @@ -19,8 +21,8 @@ class SentrySupabaseClient extends BaseClient { final table = url.pathSegments.last; final description = 'from($table)'; final operation = extractOperation(method, headers); - - if (operation != null) { + + if (operation != null && _breadcrumbs) { _addBreadcrumb(description, operation: operation); } diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index 32124ce90a..8f1dcf2fe9 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -135,6 +135,48 @@ void main() { expect(breadcrumb.category, 'db.delete'); expect(breadcrumb.type, 'supabase'); }); + + test('does not add breadcrumb when breadcrumbs are disabled', () async { + final sentrySupabaseClient = fixture.getSut(breadcrumbs: false); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase.from('countries').select(); + } catch (e) { + print(e); + } + + try { + await supabase.from('countries').insert({}); + } catch (e) { + print(e); + } + + try { + await supabase.from('countries').upsert({}); + } catch (e) { + print(e); + } + + try { + await supabase.from('countries').update({}); + } catch (e) { + print(e); + } + + try { + await supabase.from('countries').delete(); + } catch (e) { + print(e); + } + + expect(fixture.mockHub.addBreadcrumbCalls.length, 0); + }); + }); } @@ -145,8 +187,9 @@ class Fixture { final mockClient = MockClient(); final mockHub = MockHub(); - SentrySupabaseClient getSut() { + SentrySupabaseClient getSut({bool breadcrumbs = true}) { return SentrySupabaseClient( + breadcrumbs: breadcrumbs, client: mockClient, hub: mockHub, ); From cac23c48f2506932f37c7351aa3c0078cf1aa9af Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 14:34:51 +0200 Subject: [PATCH 06/32] add query to breadcrumb --- supabase/lib/src/sentry_supabase_client.dart | 98 +++++++++++++++++-- .../test/sentry_supabase_client_test.dart | 14 ++- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 476459e2b6..e3de9cbecb 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -7,6 +7,36 @@ class SentrySupabaseClient extends BaseClient { final Client _client; final Hub _hub; + 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", + }; + SentrySupabaseClient({required bool breadcrumbs, Client? client, Hub? hub}) : _breadcrumbs = breadcrumbs, _client = client ?? Client(), @@ -14,31 +44,49 @@ class SentrySupabaseClient extends BaseClient { @override Future send(BaseRequest request) { - final url = request.url; final method = request.method; final headers = request.headers; + final operation = _extractOperation(method, headers); + + if (operation != null) { + _instrument(request, operation); + } + + return _client.send(request); + } + void _instrument(BaseRequest request, Operation operation) { + final url = request.url; final table = url.pathSegments.last; final description = 'from($table)'; - final operation = extractOperation(method, headers); - - if (operation != null && _breadcrumbs) { - _addBreadcrumb(description, operation: operation); + + final query = []; + for (final entry in request.url.queryParameters.entries) { + query.add(_translateFiltersIntoMethods(entry.key, entry.value)); } - return _client.send(request); + if (_breadcrumbs) { + _addBreadcrumb(description, operation, query); + } } - void _addBreadcrumb(String description, {required Operation operation}) { + void _addBreadcrumb(String description, Operation operation, List query) { final breadcrumb = Breadcrumb( message: description, category: 'db.${operation.value}', type: 'supabase', ); + + if (query.isNotEmpty) { + breadcrumb.data = { + 'query': query, + }; + } + _hub.addBreadcrumb(breadcrumb); } - Operation? extractOperation(String method, Map headers) { + Operation? _extractOperation(String method, Map headers) { switch (method) { case "GET": { @@ -66,4 +114,38 @@ class SentrySupabaseClient extends BaseClient { } } } + + 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)"; + } } diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index 8f1dcf2fe9..f33a1e1a0e 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -40,7 +40,7 @@ void main() { ); try { - await supabase.from('countries').select(); + await supabase.from('countries').select().eq('id', 42); } catch (e) { print(e); } @@ -50,6 +50,7 @@ void main() { expect(breadcrumb.message, 'from(countries)'); expect(breadcrumb.category, 'db.select'); expect(breadcrumb.type, 'supabase'); + expect(breadcrumb.data?['query'], ['select(*)', 'eq(id, 42)']); }); test('insert adds a breadcrumb', () async { @@ -61,7 +62,7 @@ void main() { ); try { - await supabase.from('countries').insert({}); + await supabase.from('countries').insert({ 'id': 42 }); } catch (e) { print(e); } @@ -82,7 +83,7 @@ void main() { ); try { - await supabase.from('countries').upsert({}); + await supabase.from('countries').upsert({ 'id': 42 }).select(); } catch (e) { print(e); } @@ -92,6 +93,7 @@ void main() { expect(breadcrumb.message, 'from(countries)'); expect(breadcrumb.category, 'db.upsert'); expect(breadcrumb.type, 'supabase'); + expect(breadcrumb.data?['query'], ['select(*)']); }); test('update adds a breadcrumb', () async { @@ -103,7 +105,7 @@ void main() { ); try { - await supabase.from('countries').update({}); + await supabase.from('countries').update({ 'id': 1337 }).eq('id', 42); } catch (e) { print(e); } @@ -113,6 +115,7 @@ void main() { expect(breadcrumb.message, 'from(countries)'); expect(breadcrumb.category, 'db.update'); expect(breadcrumb.type, 'supabase'); + expect(breadcrumb.data?['query'], ['eq(id, 42)']); }); test('delete adds a breadcrumb', () async { @@ -124,7 +127,7 @@ void main() { ); try { - await supabase.from('countries').delete(); + await supabase.from('countries').delete().eq('id', 42); } catch (e) { print(e); } @@ -134,6 +137,7 @@ void main() { expect(breadcrumb.message, 'from(countries)'); expect(breadcrumb.category, 'db.delete'); expect(breadcrumb.type, 'supabase'); + expect(breadcrumb.data?['query'], ['eq(id, 42)']); }); test('does not add breadcrumb when breadcrumbs are disabled', () async { From 7bc29d3ef7b803d4a9c5ee6b10af7fdaf28334d7 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 14:37:19 +0200 Subject: [PATCH 07/32] format --- supabase/lib/src/sentry_supabase_client.dart | 25 ++++++++++++------- .../test/sentry_supabase_client_test.dart | 9 +++---- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index e3de9cbecb..ca30991e31 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -6,7 +6,7 @@ class SentrySupabaseClient extends BaseClient { final bool _breadcrumbs; final Client _client; final Hub _hub; - + static const Map filterMappings = { "eq": "eq", "neq": "neq", @@ -37,17 +37,20 @@ class SentrySupabaseClient extends BaseClient { "not": "not", }; - SentrySupabaseClient({required bool breadcrumbs, Client? client, Hub? hub}) : - _breadcrumbs = breadcrumbs, - _client = client ?? Client(), - _hub = hub ?? HubAdapter(); - + SentrySupabaseClient({ + required bool breadcrumbs, + Client? client, + Hub? hub, + }) : _breadcrumbs = breadcrumbs, + _client = client ?? Client(), + _hub = hub ?? HubAdapter(); + @override Future send(BaseRequest request) { final method = request.method; final headers = request.headers; final operation = _extractOperation(method, headers); - + if (operation != null) { _instrument(request, operation); } @@ -70,7 +73,11 @@ class SentrySupabaseClient extends BaseClient { } } - void _addBreadcrumb(String description, Operation operation, List query) { + void _addBreadcrumb( + String description, + Operation operation, + List query, + ) { final breadcrumb = Breadcrumb( message: description, category: 'db.${operation.value}', @@ -82,7 +89,7 @@ class SentrySupabaseClient extends BaseClient { 'query': query, }; } - + _hub.addBreadcrumb(breadcrumb); } diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index f33a1e1a0e..22d016c50d 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -20,7 +20,7 @@ void main() { group('Client', () { test('calls send on inner client', () async { final sentrySupabaseClient = fixture.getSut(); - + final request = Request('GET', Uri.parse('https://example.com/123')); await sentrySupabaseClient.send(request); @@ -62,7 +62,7 @@ void main() { ); try { - await supabase.from('countries').insert({ 'id': 42 }); + await supabase.from('countries').insert({'id': 42}); } catch (e) { print(e); } @@ -83,7 +83,7 @@ void main() { ); try { - await supabase.from('countries').upsert({ 'id': 42 }).select(); + await supabase.from('countries').upsert({'id': 42}).select(); } catch (e) { print(e); } @@ -105,7 +105,7 @@ void main() { ); try { - await supabase.from('countries').update({ 'id': 1337 }).eq('id', 42); + await supabase.from('countries').update({'id': 1337}).eq('id', 42); } catch (e) { print(e); } @@ -180,7 +180,6 @@ void main() { expect(fixture.mockHub.addBreadcrumbCalls.length, 0); }); - }); } From d4b10363266060310e7d6df6be04fea7d562c6a0 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 14:50:27 +0200 Subject: [PATCH 08/32] add body to breadcrumb --- supabase/lib/src/sentry_supabase_client.dart | 20 +++++++++++++++---- .../test/sentry_supabase_client_test.dart | 3 +++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index ca30991e31..0d3563038a 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -1,6 +1,7 @@ import 'package:http/http.dart'; import 'operation.dart'; import 'package:sentry/sentry.dart'; +import 'dart:convert'; class SentrySupabaseClient extends BaseClient { final bool _breadcrumbs; @@ -68,8 +69,12 @@ class SentrySupabaseClient extends BaseClient { query.add(_translateFiltersIntoMethods(entry.key, entry.value)); } + final bodyString = + request is Request && request.body.isNotEmpty ? request.body : null; + final body = bodyString != null ? jsonDecode(bodyString) : null; + if (_breadcrumbs) { - _addBreadcrumb(description, operation, query); + _addBreadcrumb(description, operation, query, body); } } @@ -77,6 +82,7 @@ class SentrySupabaseClient extends BaseClient { String description, Operation operation, List query, + Map? body, ) { final breadcrumb = Breadcrumb( message: description, @@ -84,10 +90,16 @@ class SentrySupabaseClient extends BaseClient { type: 'supabase', ); + if (query.isNotEmpty || body != null) { + breadcrumb.data = {}; + } + if (query.isNotEmpty) { - breadcrumb.data = { - 'query': query, - }; + breadcrumb.data?['query'] = query; + } + + if (body != null) { + breadcrumb.data?['body'] = body; } _hub.addBreadcrumb(breadcrumb); diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index 22d016c50d..39d7665557 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -72,6 +72,7 @@ void main() { expect(breadcrumb.message, 'from(countries)'); expect(breadcrumb.category, 'db.insert'); expect(breadcrumb.type, 'supabase'); + expect(breadcrumb.data?['body'], {'id': 42}); }); test('upsert adds a breadcrumb', () async { @@ -94,6 +95,7 @@ void main() { expect(breadcrumb.category, 'db.upsert'); expect(breadcrumb.type, 'supabase'); expect(breadcrumb.data?['query'], ['select(*)']); + expect(breadcrumb.data?['body'], {'id': 42}); }); test('update adds a breadcrumb', () async { @@ -116,6 +118,7 @@ void main() { expect(breadcrumb.category, 'db.update'); expect(breadcrumb.type, 'supabase'); expect(breadcrumb.data?['query'], ['eq(id, 42)']); + expect(breadcrumb.data?['body'], {'id': 1337}); }); test('delete adds a breadcrumb', () async { From 56f7aa82c1b6e154ef492eca6677a28abc704d4d Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 15:03:44 +0200 Subject: [PATCH 09/32] implement body redaction --- supabase/lib/src/sentry_supabase_client.dart | 17 ++++- .../test/sentry_supabase_client_test.dart | 66 ++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 0d3563038a..451ffe58b9 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -3,8 +3,15 @@ import 'operation.dart'; import 'package:sentry/sentry.dart'; import 'dart:convert'; +typedef SentrySupabaseRedactRequestBody = String? Function( + String table, + String key, + String value, +); + class SentrySupabaseClient extends BaseClient { final bool _breadcrumbs; + final SentrySupabaseRedactRequestBody? _redactRequestBody; final Client _client; final Hub _hub; @@ -40,9 +47,11 @@ class SentrySupabaseClient extends BaseClient { SentrySupabaseClient({ required bool breadcrumbs, + SentrySupabaseRedactRequestBody? redactRequestBody, Client? client, Hub? hub, }) : _breadcrumbs = breadcrumbs, + _redactRequestBody = redactRequestBody, _client = client ?? Client(), _hub = hub ?? HubAdapter(); @@ -71,7 +80,13 @@ class SentrySupabaseClient extends BaseClient { final bodyString = request is Request && request.body.isNotEmpty ? request.body : null; - final body = bodyString != null ? jsonDecode(bodyString) : null; + var body = bodyString != null ? jsonDecode(bodyString) : null; + + if (_redactRequestBody != null) { + for (final entry in body?.entries ?? []) { + body[entry.key] = _redactRequestBody(table, entry.key, entry.value); + } + } if (_breadcrumbs) { _addBreadcrumb(description, operation, query, body); diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index 39d7665557..9131919166 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -183,6 +183,67 @@ void main() { expect(fixture.mockHub.addBreadcrumbCalls.length, 0); }); + + test('redact request body', () async { + final sentrySupabaseClient = fixture.getSut( + redactRequestBody: (table, key, value) { + switch (key) { + case "password": + return ""; + case "token": + return ""; + case "secret": + return ""; + case "null-me": + return null; + default: + { + return value; + } + } + }, + ); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase.from("mock-table").insert( + {'user': 'picklerick', 'password': 'whoops', 'null-me': 'foo'}); + } catch (e) { + print(e); + } + + try { + await supabase + .from("mock-table") + .upsert({'user': 'picklerick', 'token': 'whoops'}); + } catch (e) { + print(e); + } + + try { + await supabase + .from("mock-table") + .update({'user': 'picklerick', 'secret': 'whoops'}).eq("id", 42); + } catch (e) { + print(e); + } + + expect(fixture.mockHub.addBreadcrumbCalls.length, 3); + final inserted = fixture.mockHub.addBreadcrumbCalls[0].$1; + expect(inserted.data?['body'], + {'user': 'picklerick', 'password': '', 'null-me': null}); + + final upserted = fixture.mockHub.addBreadcrumbCalls[1].$1; + expect(upserted.data?['body'], {'user': 'picklerick', 'token': ''}); + + final updated = fixture.mockHub.addBreadcrumbCalls[2].$1; + expect( + updated.data?['body'], {'user': 'picklerick', 'secret': ''}); + }); }); } @@ -193,11 +254,14 @@ class Fixture { final mockClient = MockClient(); final mockHub = MockHub(); - SentrySupabaseClient getSut({bool breadcrumbs = true}) { + SentrySupabaseClient getSut( + {bool breadcrumbs = true, + SentrySupabaseRedactRequestBody? redactRequestBody}) { return SentrySupabaseClient( breadcrumbs: breadcrumbs, client: mockClient, hub: mockHub, + redactRequestBody: redactRequestBody, ); } } From f1df16d4bf598ef0e00930ce02e7b5ee44275383 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 15:08:47 +0200 Subject: [PATCH 10/32] format --- supabase/lib/src/sentry_supabase_client.dart | 26 ++++++------------- .../test/sentry_supabase_client_test.dart | 4 +-- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 451ffe58b9..6296e9546b 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -123,29 +123,19 @@ class SentrySupabaseClient extends BaseClient { Operation? _extractOperation(String method, Map headers) { switch (method) { case "GET": - { - return Operation.select; - } + return Operation.select; case "POST": - { - if (headers["Prefer"]?.contains("resolution=") ?? false) { - return Operation.upsert; - } else { - return Operation.insert; - } + if (headers["Prefer"]?.contains("resolution=") ?? false) { + return Operation.upsert; + } else { + return Operation.insert; } case "PATCH": - { - return Operation.update; - } + return Operation.update; case "DELETE": - { - return Operation.delete; - } + return Operation.delete; default: - { - return null; - } + return null; } } diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index 9131919166..420912bf29 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -197,9 +197,7 @@ void main() { case "null-me": return null; default: - { - return value; - } + return value; } }, ); From a7b8b280479931c65c061c5710eb5be400cacbc0 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 6 May 2025 15:13:23 +0200 Subject: [PATCH 11/32] refactor --- supabase/lib/src/sentry_supabase_client.dart | 26 +++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 6296e9546b..69fffcc6c8 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -72,25 +72,33 @@ class SentrySupabaseClient extends BaseClient { final url = request.url; final table = url.pathSegments.last; final description = 'from($table)'; + final query = _readQuery(request); + final body = _readBody(table, request); - final query = []; - for (final entry in request.url.queryParameters.entries) { - query.add(_translateFiltersIntoMethods(entry.key, entry.value)); + if (_breadcrumbs) { + _addBreadcrumb(description, operation, query, body); } + } + + List _readQuery(BaseRequest request) { + return request.url.queryParameters.entries + .map( + (entry) => _translateFiltersIntoMethods(entry.key, entry.value), + ) + .toList(); + } + Map? _readBody(String table, BaseRequest request) { final bodyString = request is Request && request.body.isNotEmpty ? request.body : null; var body = bodyString != null ? jsonDecode(bodyString) : null; - if (_redactRequestBody != null) { - for (final entry in body?.entries ?? []) { + if (body != null && _redactRequestBody != null) { + for (final entry in body.entries) { body[entry.key] = _redactRequestBody(table, entry.key, entry.value); } } - - if (_breadcrumbs) { - _addBreadcrumb(description, operation, query, body); - } + return body; } void _addBreadcrumb( From 6ee6dfbf6d2a29d6c5c734c0cf9bb324fa65d43e Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 19 May 2025 17:58:14 +0200 Subject: [PATCH 12/32] basic tracing support --- supabase/lib/src/sentry_supabase_client.dart | 53 ++++++-- .../test/sentry_supabase_client_test.dart | 117 +++++++++++++++++- 2 files changed, 157 insertions(+), 13 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 69fffcc6c8..b718bd2de5 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -56,19 +56,27 @@ class SentrySupabaseClient extends BaseClient { _hub = hub ?? HubAdapter(); @override - Future send(BaseRequest request) { + Future send(BaseRequest request) async { final method = request.method; final headers = request.headers; final operation = _extractOperation(method, headers); + if (operation == null) { + return _client.send(request); + } + + final span = _instrument(request, operation); + + final response = await _client.send(request); - if (operation != null) { - _instrument(request, operation); + if (response.statusCode == 200) { + span.setData('http.response.status_code', response.statusCode); + span.finish(status: SpanStatus.ok()); } - return _client.send(request); + return response; } - void _instrument(BaseRequest request, Operation operation) { + ISentrySpan _instrument(BaseRequest request, Operation operation) { final url = request.url; final table = url.pathSegments.last; final description = 'from($table)'; @@ -78,14 +86,39 @@ class SentrySupabaseClient extends BaseClient { if (_breadcrumbs) { _addBreadcrumb(description, operation, query, body); } + + ISentrySpan span = NoOpSentrySpan(); + // ignore: invalid_use_of_internal_member + if (_hub.options.isTracingEnabled()) { + span = _hub.startTransaction(description, 'db.${operation.value}'); + } + + final dbSchema = request.headers["Accept-Profile"]; + if (dbSchema != null) { + span.setData('db.schema', dbSchema); + } + span.setData('db.table', table); + span.setData('db.url', url.origin); + final dbSdk = request.headers["X-Client-Info"]; + if (dbSdk != null) { + span.setData('db.sdk', dbSdk); + } + span.setData('db.query', query); + + span.setData('op', 'db.${operation.value}'); + span.setData('origin', 'auto.db.supabase'); + + return span; } List _readQuery(BaseRequest request) { - return request.url.queryParameters.entries - .map( - (entry) => _translateFiltersIntoMethods(entry.key, entry.value), - ) - .toList(); + return request.url.queryParametersAll.entries + .expand( + (entry) => entry.value.map( + (value) => _translateFiltersIntoMethods(entry.key, value), + ), + ) + .toList(); } Map? _readBody(String table, BaseRequest request) { diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index 420912bf29..b9471dd27b 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -9,7 +9,7 @@ import 'dart:async'; import 'package:supabase/supabase.dart'; void main() { - const supabaseUrl = 'YOUR_SUPABASE_URL'; + const supabaseUrl = 'https://example.com'; const supabaseKey = 'YOUR_ANON_KEY'; late Fixture fixture; @@ -243,6 +243,63 @@ void main() { updated.data?['body'], {'user': 'picklerick', 'secret': ''}); }); }); + + group('Tracing', () { + void verifyCommonSpanAttributes(MockSpan span) { + 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'], 'supabase-dart/2.6.3'); // TODO: Read version from dependency + expect(span.data['origin'], 'auto.db.supabase'); + } + + test('should create trace for select', () async { + fixture.mockClient.jsonResponse = '{"id": 42}'; + + final sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase + .from("mock-table") + .select() + .lt("id", 42) + .gt("id", 20) + .not("id", "eq", 32); + } catch (e) { + print(e); + } + + expect(fixture.mockHub.startTransactionCalls.length, 1); + final startTransactionCalls = fixture.mockHub.startTransactionCalls.first; + expect(startTransactionCalls.$1, 'from(mock-table)'); // name + expect(startTransactionCalls.$2, 'db.select'); // operation + + var span = fixture.mockHub.mockSpan; + + verifyCommonSpanAttributes(span); + + expect(span.data['db.query'], [ + "select(*)", + "lt(id, 42)", + "gt(id, 20)", + "not(id, eq.32)", + ]); + expect(span.data['op'], 'db.select'); + + // Finished successfully + + expect(span.data['http.response.status_code'], 200); + + expect(span.finishCalls.length, 1); + final finishCall = span.finishCalls.first; + expect(finishCall.$1, SpanStatus.ok()); + }); + }); } class Fixture { @@ -250,7 +307,11 @@ class Fixture { dsn: 'https://example.com/123', ); final mockClient = MockClient(); - final mockHub = MockHub(); + late final mockHub = MockHub(options); + + Fixture() { + options.tracesSampleRate = 1.0; // enable tracing + } SentrySupabaseClient getSut( {bool breadcrumbs = true, @@ -268,21 +329,71 @@ class MockClient extends BaseClient { final sendCalls = []; final closeCalls = []; + var jsonResponse = '{}'; + @override Future send(BaseRequest request) async { sendCalls.add(request); - return StreamedResponse(Stream.value(utf8.encode('{}')), 200); + return StreamedResponse(Stream.value(utf8.encode(jsonResponse)), 200); } } class MockHub implements Hub { + MockHub(this._options); + SentryOptions _options; + + @override + SentryOptions get options => _options; + final addBreadcrumbCalls = <(Breadcrumb, Hint?)>[]; + final startTransactionCalls = <(String, String)>[]; @override Future addBreadcrumb(Breadcrumb crumb, {Hint? hint}) async { addBreadcrumbCalls.add((crumb, hint)); } + var mockSpan = MockSpan(); + + 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; + } + + // 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?)>[]; + + @override + void setData(String key, dynamic value) { + data[key] = value; + } + + @override + Future finish({SpanStatus? status, DateTime? endTimestamp, Hint? hint}) { + finishCalls.add((status, endTimestamp, hint)); + return Future.value(); + } + // No such method @override void noSuchMethod(Invocation invocation) { From f6e68ae0c927ad671c6546697185f2c687569f3c Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 20 May 2025 10:31:10 +0200 Subject: [PATCH 13/32] cleanup --- supabase/lib/src/sentry_supabase_client.dart | 96 +++++++++---------- .../test/sentry_supabase_client_test.dart | 49 +++++++--- 2 files changed, 79 insertions(+), 66 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index b718bd2de5..94ed86b16a 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -68,10 +68,9 @@ class SentrySupabaseClient extends BaseClient { final response = await _client.send(request); - if (response.statusCode == 200) { - span.setData('http.response.status_code', response.statusCode); - span.finish(status: SpanStatus.ok()); - } + span.setData('http.response.status_code', response.statusCode); + final status = SpanStatus.fromHttpStatusCode(response.statusCode); + span.finish(status: status); return response; } @@ -83,42 +82,60 @@ class SentrySupabaseClient extends BaseClient { final query = _readQuery(request); final body = _readBody(table, request); + // Breadcrumb + if (_breadcrumbs) { - _addBreadcrumb(description, operation, query, body); + final breadcrumb = Breadcrumb( + message: description, + category: 'db.${operation.value}', + type: 'supabase', + ); + if (query.isNotEmpty || body != null) { + breadcrumb.data = {}; + if (query.isNotEmpty) { + breadcrumb.data?['query'] = query; + } + if (body != null) { + breadcrumb.data?['body'] = body; + } + } + _hub.addBreadcrumb(breadcrumb); } + // Tracing + ISentrySpan span = NoOpSentrySpan(); // ignore: invalid_use_of_internal_member if (_hub.options.isTracingEnabled()) { span = _hub.startTransaction(description, 'db.${operation.value}'); - } - final dbSchema = request.headers["Accept-Profile"]; - if (dbSchema != null) { - span.setData('db.schema', dbSchema); - } - span.setData('db.table', table); - span.setData('db.url', url.origin); - final dbSdk = request.headers["X-Client-Info"]; - if (dbSdk != null) { - span.setData('db.sdk', dbSdk); - } - span.setData('db.query', query); + final dbSchema = request.headers["Accept-Profile"]; + if (dbSchema != null) { + span.setData('db.schema', dbSchema); + } + span.setData('db.table', table); + span.setData('db.url', url.origin); + final dbSdk = request.headers["X-Client-Info"]; + if (dbSdk != null) { + span.setData('db.sdk', dbSdk); + } + span.setData('db.query', query); - span.setData('op', 'db.${operation.value}'); - span.setData('origin', 'auto.db.supabase'); + span.setData('op', 'db.${operation.value}'); + span.setData('origin', 'auto.db.supabase'); + } return span; } List _readQuery(BaseRequest request) { - return request.url.queryParametersAll.entries - .expand( - (entry) => entry.value.map( - (value) => _translateFiltersIntoMethods(entry.key, value), - ), - ) - .toList(); + return request.url.queryParametersAll.entries + .expand( + (entry) => entry.value.map( + (value) => _translateFiltersIntoMethods(entry.key, value), + ), + ) + .toList(); } Map? _readBody(String table, BaseRequest request) { @@ -134,33 +151,6 @@ class SentrySupabaseClient extends BaseClient { return body; } - void _addBreadcrumb( - String description, - Operation operation, - List query, - Map? body, - ) { - final breadcrumb = Breadcrumb( - message: description, - category: 'db.${operation.value}', - type: 'supabase', - ); - - if (query.isNotEmpty || body != null) { - breadcrumb.data = {}; - } - - if (query.isNotEmpty) { - breadcrumb.data?['query'] = query; - } - - if (body != null) { - breadcrumb.data?['body'] = body; - } - - _hub.addBreadcrumb(breadcrumb); - } - Operation? _extractOperation(String method, Map headers) { switch (method) { case "GET": diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index b9471dd27b..b8f15d337b 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'dart:async'; +import 'package:supabase/supabase.dart'; import 'package:supabase/supabase.dart'; void main() { @@ -245,14 +246,33 @@ void main() { }); group('Tracing', () { - void verifyCommonSpanAttributes(MockSpan span) { + void verifyCommonSpanAttributes(MockSpan span, String version) { 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'], 'supabase-dart/2.6.3'); // TODO: Read version from dependency + expect(span.data['db.sdk'], version); expect(span.data['origin'], 'auto.db.supabase'); } + test('should not create trace if disabled', () async { + fixture.options.tracesSampleRate = null; + + final sentrySupabaseClient = fixture.getSut(breadcrumbs: false); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + try { + await supabase.from('mock-table').select(); + } catch (e) { + print(e); + } + + expect(fixture.mockHub.startTransactionCalls.length, 0); + }); + test('should create trace for select', () async { fixture.mockClient.jsonResponse = '{"id": 42}'; @@ -265,11 +285,11 @@ void main() { try { await supabase - .from("mock-table") - .select() - .lt("id", 42) - .gt("id", 20) - .not("id", "eq", 32); + .from("mock-table") + .select() + .lt("id", 42) + .gt("id", 20) + .not("id", "eq", 32); } catch (e) { print(e); } @@ -281,7 +301,7 @@ void main() { var span = fixture.mockHub.mockSpan; - verifyCommonSpanAttributes(span); + verifyCommonSpanAttributes(span, supabase.headers['X-Client-Info'] ?? ""); expect(span.data['db.query'], [ "select(*)", @@ -313,9 +333,10 @@ class Fixture { options.tracesSampleRate = 1.0; // enable tracing } - SentrySupabaseClient getSut( - {bool breadcrumbs = true, - SentrySupabaseRedactRequestBody? redactRequestBody}) { + SentrySupabaseClient getSut({ + bool breadcrumbs = true, + SentrySupabaseRedactRequestBody? redactRequestBody, + }) { return SentrySupabaseClient( breadcrumbs: breadcrumbs, client: mockClient, @@ -340,7 +361,7 @@ class MockClient extends BaseClient { class MockHub implements Hub { MockHub(this._options); - SentryOptions _options; + final SentryOptions _options; @override SentryOptions get options => _options; @@ -355,6 +376,7 @@ class MockHub implements Hub { var mockSpan = MockSpan(); + @override ISentrySpan startTransaction( String name, String operation, { @@ -389,7 +411,8 @@ class MockSpan implements ISentrySpan { } @override - Future finish({SpanStatus? status, DateTime? endTimestamp, Hint? hint}) { + Future finish( + {SpanStatus? status, DateTime? endTimestamp, Hint? hint}) { finishCalls.add((status, endTimestamp, hint)); return Future.value(); } From 6fc8c8558b94d6dfc8256682b6fbd4e831438135 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 20 May 2025 11:08:09 +0200 Subject: [PATCH 14/32] instument insert --- supabase/lib/src/sentry_supabase_client.dart | 11 +- .../test/sentry_supabase_client_test.dart | 130 +++++++++--------- 2 files changed, 72 insertions(+), 69 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 94ed86b16a..4794853fb7 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -109,7 +109,8 @@ class SentrySupabaseClient extends BaseClient { if (_hub.options.isTracingEnabled()) { span = _hub.startTransaction(description, 'db.${operation.value}'); - final dbSchema = request.headers["Accept-Profile"]; + final dbSchema = request.headers["Accept-Profile"] ?? + request.headers["Content-Profile"]; if (dbSchema != null) { span.setData('db.schema', dbSchema); } @@ -119,8 +120,12 @@ class SentrySupabaseClient extends BaseClient { if (dbSdk != null) { span.setData('db.sdk', dbSdk); } - span.setData('db.query', query); - + if (query.isNotEmpty) { + span.setData('db.query', query); + } + if (body != null) { + span.setData('db.body', body); + } span.setData('op', 'db.${operation.value}'); span.setData('origin', 'auto.db.supabase'); } diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index b8f15d337b..d08573a913 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -10,8 +10,6 @@ import 'package:supabase/supabase.dart'; import 'package:supabase/supabase.dart'; void main() { - const supabaseUrl = 'https://example.com'; - const supabaseKey = 'YOUR_ANON_KEY'; late Fixture fixture; setUp(() { @@ -33,12 +31,7 @@ void main() { group('Breadcrumb', () { test('select adds a breadcrumb', () async { - final sentrySupabaseClient = fixture.getSut(); - final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: sentrySupabaseClient, - ); + final supabase = fixture.getSupabaseClient(); try { await supabase.from('countries').select().eq('id', 42); @@ -55,12 +48,7 @@ void main() { }); test('insert adds a breadcrumb', () async { - final sentrySupabaseClient = fixture.getSut(); - final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: sentrySupabaseClient, - ); + final supabase = fixture.getSupabaseClient(); try { await supabase.from('countries').insert({'id': 42}); @@ -77,12 +65,7 @@ void main() { }); test('upsert adds a breadcrumb', () async { - final sentrySupabaseClient = fixture.getSut(); - final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: sentrySupabaseClient, - ); + final supabase = fixture.getSupabaseClient(); try { await supabase.from('countries').upsert({'id': 42}).select(); @@ -100,12 +83,7 @@ void main() { }); test('update adds a breadcrumb', () async { - final sentrySupabaseClient = fixture.getSut(); - final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: sentrySupabaseClient, - ); + final supabase = fixture.getSupabaseClient(); try { await supabase.from('countries').update({'id': 1337}).eq('id', 42); @@ -123,12 +101,7 @@ void main() { }); test('delete adds a breadcrumb', () async { - final sentrySupabaseClient = fixture.getSut(); - final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: sentrySupabaseClient, - ); + final supabase = fixture.getSupabaseClient(); try { await supabase.from('countries').delete().eq('id', 42); @@ -145,12 +118,7 @@ void main() { }); test('does not add breadcrumb when breadcrumbs are disabled', () async { - final sentrySupabaseClient = fixture.getSut(breadcrumbs: false); - final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: sentrySupabaseClient, - ); + final supabase = fixture.getSupabaseClient(); try { await supabase.from('countries').select(); @@ -203,8 +171,8 @@ void main() { }, ); final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, + fixture.supabaseUrl, + fixture.supabaseKey, httpClient: sentrySupabaseClient, ); @@ -246,7 +214,15 @@ void main() { }); group('Tracing', () { - void verifyCommonSpanAttributes(MockSpan span, String version) { + void verifyStartTransaction(String operation) { + expect(fixture.mockHub.startTransactionCalls.length, 1); + final startTransactionCalls = fixture.mockHub.startTransactionCalls.first; + expect(startTransactionCalls.$1, 'from(mock-table)'); // name + expect(startTransactionCalls.$2, 'db.$operation'); + } + + void verifyCommonSpanAttributes(String version) { + final span = fixture.mockHub.mockSpan; expect(span.data['db.schema'], 'public'); expect(span.data['db.table'], 'mock-table'); expect(span.data['db.url'], 'https://example.com'); @@ -254,15 +230,17 @@ void main() { expect(span.data['origin'], 'auto.db.supabase'); } + void verifyFinishSpan() { + final span = fixture.mockHub.mockSpan; + expect(span.finishCalls.length, 1); + final finishCall = span.finishCalls.first; + expect(finishCall.$1, SpanStatus.ok()); + } + test('should not create trace if disabled', () async { fixture.options.tracesSampleRate = null; - final sentrySupabaseClient = fixture.getSut(breadcrumbs: false); - final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: sentrySupabaseClient, - ); + final supabase = fixture.getSupabaseClient(breadcrumbs: false); try { await supabase.from('mock-table').select(); @@ -276,12 +254,7 @@ void main() { test('should create trace for select', () async { fixture.mockClient.jsonResponse = '{"id": 42}'; - final sentrySupabaseClient = fixture.getSut(); - final supabase = SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: sentrySupabaseClient, - ); + final supabase = fixture.getSupabaseClient(breadcrumbs: false); try { await supabase @@ -294,15 +267,11 @@ void main() { print(e); } - expect(fixture.mockHub.startTransactionCalls.length, 1); - final startTransactionCalls = fixture.mockHub.startTransactionCalls.first; - expect(startTransactionCalls.$1, 'from(mock-table)'); // name - expect(startTransactionCalls.$2, 'db.select'); // operation - - var span = fixture.mockHub.mockSpan; - - verifyCommonSpanAttributes(span, supabase.headers['X-Client-Info'] ?? ""); + verifyStartTransaction('select'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + final span = fixture.mockHub.mockSpan; expect(span.data['db.query'], [ "select(*)", "lt(id, 42)", @@ -310,19 +279,34 @@ void main() { "not(id, eq.32)", ]); expect(span.data['op'], 'db.select'); + }); - // Finished successfully + test('should create trace for insert', () async { + fixture.mockClient.jsonResponse = '{"id": 42}'; - expect(span.data['http.response.status_code'], 200); + final supabase = fixture.getSupabaseClient(breadcrumbs: false); - expect(span.finishCalls.length, 1); - final finishCall = span.finishCalls.first; - expect(finishCall.$1, SpanStatus.ok()); + try { + await supabase.from("mock-table").insert({"id": 42}); + } catch (e) { + print(e); + } + + verifyStartTransaction('insert'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.body'], {'id': 42}); + expect(span.data['op'], 'db.insert'); }); }); } class Fixture { + final supabaseUrl = 'https://example.com'; + final supabaseKey = 'YOUR_ANON_KEY'; + final options = SentryOptions( dsn: 'https://example.com/123', ); @@ -344,6 +328,20 @@ class Fixture { redactRequestBody: redactRequestBody, ); } + + SupabaseClient getSupabaseClient({ + bool breadcrumbs = true, + SentrySupabaseRedactRequestBody? redactRequestBody, + }) { + return SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: getSut( + breadcrumbs: breadcrumbs, + redactRequestBody: redactRequestBody, + ), + ); + } } class MockClient extends BaseClient { From 0f77a5faffe3d95741238c0e3e1e12b29d392890 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 20 May 2025 11:46:27 +0200 Subject: [PATCH 15/32] add renamining span tests --- .../test/sentry_supabase_client_test.dart | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index d08573a913..432e7356aa 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -300,6 +300,89 @@ void main() { expect(span.data['db.body'], {'id': 42}); expect(span.data['op'], 'db.insert'); }); + + test('should create trace for upsert', () async { + fixture.mockClient.jsonResponse = '{"id": 42}'; + + final supabase = fixture.getSupabaseClient(breadcrumbs: false); + + try { + await supabase.from("mock-table").upsert({"id": 42}).select("id,name"); + } catch (e) { + print(e); + } + + verifyStartTransaction('upsert'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.body'], {'id': 42}); + expect(span.data['db.query'], ['select(id,name)']); + expect(span.data['op'], 'db.upsert'); + }); + + test('should create trace for update', () async { + fixture.mockClient.jsonResponse = '{"id": 1337}'; + + final supabase = fixture.getSupabaseClient(breadcrumbs: false); + + try { + await supabase + .from("mock-table") + .update({"id": 1337}) + .eq("id", 42) + .or("id.eq.8"); + } catch (e) { + print(e); + } + + verifyStartTransaction('update'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.body'], {'id': 1337}); + expect(span.data['db.query'], ["eq(id, 42)", "or(id.eq.8)"]); + expect(span.data['op'], 'db.update'); + }); + + test('should create trace for delete', () async { + fixture.mockClient.jsonResponse = '{}'; + + final supabase = fixture.getSupabaseClient(breadcrumbs: false); + + try { + await supabase.from("mock-table").delete().eq("id", 42); + } catch (e) { + print(e); + } + + verifyStartTransaction('delete'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.query'], ["eq(id, 42)"]); + expect(span.data['op'], 'db.delete'); + }); + + test('should finish with error status if request fails', () async { + fixture.mockClient.statusCode = 404; + + final supabase = fixture.getSupabaseClient(breadcrumbs: false); + + try { + await supabase.from("mock-table").delete().eq("id", 42); + } catch (e) { + print(e); + } + + final span = fixture.mockHub.mockSpan; + expect(span.finishCalls.length, 1); + final finishCall = span.finishCalls.first; + expect(finishCall.$1, SpanStatus.fromHttpStatusCode(404)); + }); }); } @@ -349,11 +432,13 @@ class MockClient extends BaseClient { final closeCalls = []; var jsonResponse = '{}'; + var statusCode = 200; @override Future send(BaseRequest request) async { sendCalls.add(request); - return StreamedResponse(Stream.value(utf8.encode(jsonResponse)), 200); + return StreamedResponse( + Stream.value(utf8.encode(jsonResponse)), statusCode); } } From 6df4a385d72aaafe586e045282a72026f1032aaf Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 10:43:39 +0200 Subject: [PATCH 16/32] split responsibility for breadcrumbs, errors and tracing into multiple clients --- supabase/lib/sentry_supabase.dart | 1 + .../sentry_supabase_breadcrumb_client.dart | 43 ++ supabase/lib/src/sentry_supabase_client.dart | 210 -------- .../lib/src/sentry_supabase_client_error.dart | 7 + .../lib/src/sentry_supabase_error_client.dart | 73 +++ supabase/lib/src/sentry_supabase_request.dart | 143 +++++ .../src/sentry_supabase_tracing_client.dart | 72 +++ ...entry_supabase_breadcrumb_client_test.dart | 201 +++++++ .../test/sentry_supabase_client_test.dart | 509 ------------------ .../sentry_supabase_error_client_test.dart | 289 ++++++++++ .../sentry_supabase_tracing_client_test.dart | 343 ++++++++++++ 11 files changed, 1172 insertions(+), 719 deletions(-) create mode 100644 supabase/lib/src/sentry_supabase_breadcrumb_client.dart create mode 100644 supabase/lib/src/sentry_supabase_client_error.dart create mode 100644 supabase/lib/src/sentry_supabase_error_client.dart create mode 100644 supabase/lib/src/sentry_supabase_request.dart create mode 100644 supabase/lib/src/sentry_supabase_tracing_client.dart create mode 100644 supabase/test/sentry_supabase_breadcrumb_client_test.dart delete mode 100644 supabase/test/sentry_supabase_client_test.dart create mode 100644 supabase/test/sentry_supabase_error_client_test.dart create mode 100644 supabase/test/sentry_supabase_tracing_client_test.dart diff --git a/supabase/lib/sentry_supabase.dart b/supabase/lib/sentry_supabase.dart index eb796b2e8e..8e85dfab10 100644 --- a/supabase/lib/sentry_supabase.dart +++ b/supabase/lib/sentry_supabase.dart @@ -1,3 +1,4 @@ library; export 'src/sentry_supabase_client.dart'; +export 'src/sentry_supabase_client_error.dart'; 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..020334c579 --- /dev/null +++ b/supabase/lib/src/sentry_supabase_breadcrumb_client.dart @@ -0,0 +1,43 @@ +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); + + 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; + + if (supabaseRequest.query.isNotEmpty) { + breadcrumb.data?['query'] = supabaseRequest.query; + } + if (supabaseRequest.body != null) { + breadcrumb.data?['body'] = supabaseRequest.body; + } + + _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 index 4794853fb7..8b13789179 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -1,211 +1 @@ -import 'package:http/http.dart'; -import 'operation.dart'; -import 'package:sentry/sentry.dart'; -import 'dart:convert'; -typedef SentrySupabaseRedactRequestBody = String? Function( - String table, - String key, - String value, -); - -class SentrySupabaseClient extends BaseClient { - final bool _breadcrumbs; - final SentrySupabaseRedactRequestBody? _redactRequestBody; - final Client _client; - final Hub _hub; - - 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", - }; - - SentrySupabaseClient({ - required bool breadcrumbs, - SentrySupabaseRedactRequestBody? redactRequestBody, - Client? client, - Hub? hub, - }) : _breadcrumbs = breadcrumbs, - _redactRequestBody = redactRequestBody, - _client = client ?? Client(), - _hub = hub ?? HubAdapter(); - - @override - Future send(BaseRequest request) async { - final method = request.method; - final headers = request.headers; - final operation = _extractOperation(method, headers); - if (operation == null) { - return _client.send(request); - } - - final span = _instrument(request, operation); - - final response = await _client.send(request); - - span.setData('http.response.status_code', response.statusCode); - final status = SpanStatus.fromHttpStatusCode(response.statusCode); - span.finish(status: status); - - return response; - } - - ISentrySpan _instrument(BaseRequest request, Operation operation) { - final url = request.url; - final table = url.pathSegments.last; - final description = 'from($table)'; - final query = _readQuery(request); - final body = _readBody(table, request); - - // Breadcrumb - - if (_breadcrumbs) { - final breadcrumb = Breadcrumb( - message: description, - category: 'db.${operation.value}', - type: 'supabase', - ); - if (query.isNotEmpty || body != null) { - breadcrumb.data = {}; - if (query.isNotEmpty) { - breadcrumb.data?['query'] = query; - } - if (body != null) { - breadcrumb.data?['body'] = body; - } - } - _hub.addBreadcrumb(breadcrumb); - } - - // Tracing - - ISentrySpan span = NoOpSentrySpan(); - // ignore: invalid_use_of_internal_member - if (_hub.options.isTracingEnabled()) { - span = _hub.startTransaction(description, 'db.${operation.value}'); - - final dbSchema = request.headers["Accept-Profile"] ?? - request.headers["Content-Profile"]; - if (dbSchema != null) { - span.setData('db.schema', dbSchema); - } - span.setData('db.table', table); - span.setData('db.url', url.origin); - final dbSdk = request.headers["X-Client-Info"]; - if (dbSdk != null) { - span.setData('db.sdk', dbSdk); - } - if (query.isNotEmpty) { - span.setData('db.query', query); - } - if (body != null) { - span.setData('db.body', body); - } - span.setData('op', 'db.${operation.value}'); - span.setData('origin', 'auto.db.supabase'); - } - - return span; - } - - List _readQuery(BaseRequest request) { - return request.url.queryParametersAll.entries - .expand( - (entry) => entry.value.map( - (value) => _translateFiltersIntoMethods(entry.key, value), - ), - ) - .toList(); - } - - Map? _readBody(String table, BaseRequest request) { - final bodyString = - request is Request && request.body.isNotEmpty ? request.body : null; - var body = bodyString != null ? jsonDecode(bodyString) : null; - - if (body != null && _redactRequestBody != null) { - for (final entry in body.entries) { - body[entry.key] = _redactRequestBody(table, entry.key, entry.value); - } - } - return body; - } - - 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 null; - } - } - - 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)"; - } -} 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..cd98842cdf --- /dev/null +++ b/supabase/lib/src/sentry_supabase_error_client.dart @@ -0,0 +1,73 @@ +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( + Exception? 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); + event.contexts['supabase'] = { + 'table': supabaseRequest.table, + 'operation': supabaseRequest.operation.value, + if (supabaseRequest.query.isNotEmpty) 'query': supabaseRequest.query, + if (supabaseRequest.body != null) '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..3120802752 --- /dev/null +++ b/supabase/lib/src/sentry_supabase_request.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'package:http/http.dart'; + +import 'operation.dart'; + +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, + }); + + factory SentrySupabaseRequest.fromRequest(BaseRequest request) { + final url = request.url; + final table = url.pathSegments.last; + final operation = _extractOperation(request.method, request.headers); + final query = _readQuery(request); // TODO: PII + final body = _readBody(table, request); // TODO: PII + + 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.select; // 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; + var body = bodyString != null ? jsonDecode(bodyString) : null; + + // if (body != null && _redactRequestBody != null) { + // for (final entry in body.entries) { + // body[entry.key] = _redactRequestBody(table, entry.key, entry.value); + // } + // } + return body; + } + + 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)"; + } +} 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..163d80f107 --- /dev/null +++ b/supabase/lib/src/sentry_supabase_tracing_client.dart @@ -0,0 +1,72 @@ +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); + + 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 description = 'from(${supabaseRequest.table})'; + + final span = _hub.startTransaction( + description, 'db.${supabaseRequest.operation.value}'); + + 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); + } + if (supabaseRequest.query.isNotEmpty) { + span.setData('db.query', supabaseRequest.query); + } + if (supabaseRequest.body != null) { + span.setData('db.body', supabaseRequest.body); + } + span.setData('op', 'db.${supabaseRequest.operation.value}'); + span.setData('origin', 'auto.db.supabase'); + + return span; + } +} 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..b3ced0dad4 --- /dev/null +++ b/supabase/test/sentry_supabase_breadcrumb_client_test.dart @@ -0,0 +1,201 @@ +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'; + +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) { + print(e); + } + + 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?['query'], ['select(*)', 'eq(id, 42)']); + }); + + test('added on insert', () async { + final supabase = fixture.getSupabaseClient(); + + try { + await supabase.from('countries').insert({'id': 42}); + } catch (e) { + print(e); + } + + 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?['body'], {'id': 42}); + }); + + test('added on upsert', () async { + final supabase = fixture.getSupabaseClient(); + + try { + await supabase.from('countries').upsert({'id': 42}).select(); + } catch (e) { + print(e); + } + + 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?['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) { + print(e); + } + + 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?['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) { + print(e); + } + + 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?['query'], ['eq(id, 42)']); + }); + }); +} + +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.tracesSampleRate = 1.0; // enable tracing + } + + 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); + } +} + +class _MockHub implements Hub { + _MockHub(this._options); + final SentryOptions _options; + + @override + SentryOptions get options => _options; + + final addBreadcrumbCalls = <(Breadcrumb, Hint?)>[]; + + @override + Future addBreadcrumb(Breadcrumb crumb, {Hint? hint}) async { + addBreadcrumbCalls.add((crumb, hint)); + } + + // No such method + @override + void noSuchMethod(Invocation invocation) { + 'Method ${invocation.memberName} was called ' + 'with arguments ${invocation.positionalArguments}'; + } +} diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart deleted file mode 100644 index 432e7356aa..0000000000 --- a/supabase/test/sentry_supabase_client_test.dart +++ /dev/null @@ -1,509 +0,0 @@ -import 'package:sentry_supabase/sentry_supabase.dart'; -import 'package:test/test.dart'; -import 'package:sentry/sentry.dart'; -import 'package:http/http.dart'; -import 'dart:convert'; - -import 'dart:async'; - -import 'package:supabase/supabase.dart'; -import 'package:supabase/supabase.dart'; - -void main() { - late Fixture fixture; - - setUp(() { - fixture = Fixture(); - }); - - group('Client', () { - test('calls send on inner client', () async { - final sentrySupabaseClient = fixture.getSut(); - - final request = Request('GET', Uri.parse('https://example.com/123')); - - await sentrySupabaseClient.send(request); - - expect(fixture.mockClient.sendCalls.length, 1); - expect(fixture.mockClient.sendCalls.first, request); - }); - }); - - group('Breadcrumb', () { - test('select adds a breadcrumb', () async { - final supabase = fixture.getSupabaseClient(); - - try { - await supabase.from('countries').select().eq('id', 42); - } catch (e) { - print(e); - } - - 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?['query'], ['select(*)', 'eq(id, 42)']); - }); - - test('insert adds a breadcrumb', () async { - final supabase = fixture.getSupabaseClient(); - - try { - await supabase.from('countries').insert({'id': 42}); - } catch (e) { - print(e); - } - - 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?['body'], {'id': 42}); - }); - - test('upsert adds a breadcrumb', () async { - final supabase = fixture.getSupabaseClient(); - - try { - await supabase.from('countries').upsert({'id': 42}).select(); - } catch (e) { - print(e); - } - - 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?['query'], ['select(*)']); - expect(breadcrumb.data?['body'], {'id': 42}); - }); - - test('update adds a breadcrumb', () async { - final supabase = fixture.getSupabaseClient(); - - try { - await supabase.from('countries').update({'id': 1337}).eq('id', 42); - } catch (e) { - print(e); - } - - 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?['query'], ['eq(id, 42)']); - expect(breadcrumb.data?['body'], {'id': 1337}); - }); - - test('delete adds a breadcrumb', () async { - final supabase = fixture.getSupabaseClient(); - - try { - await supabase.from('countries').delete().eq('id', 42); - } catch (e) { - print(e); - } - - 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?['query'], ['eq(id, 42)']); - }); - - test('does not add breadcrumb when breadcrumbs are disabled', () async { - final supabase = fixture.getSupabaseClient(); - - try { - await supabase.from('countries').select(); - } catch (e) { - print(e); - } - - try { - await supabase.from('countries').insert({}); - } catch (e) { - print(e); - } - - try { - await supabase.from('countries').upsert({}); - } catch (e) { - print(e); - } - - try { - await supabase.from('countries').update({}); - } catch (e) { - print(e); - } - - try { - await supabase.from('countries').delete(); - } catch (e) { - print(e); - } - - expect(fixture.mockHub.addBreadcrumbCalls.length, 0); - }); - - test('redact request body', () async { - final sentrySupabaseClient = fixture.getSut( - redactRequestBody: (table, key, value) { - switch (key) { - case "password": - return ""; - case "token": - return ""; - case "secret": - return ""; - case "null-me": - return null; - default: - return value; - } - }, - ); - final supabase = SupabaseClient( - fixture.supabaseUrl, - fixture.supabaseKey, - httpClient: sentrySupabaseClient, - ); - - try { - await supabase.from("mock-table").insert( - {'user': 'picklerick', 'password': 'whoops', 'null-me': 'foo'}); - } catch (e) { - print(e); - } - - try { - await supabase - .from("mock-table") - .upsert({'user': 'picklerick', 'token': 'whoops'}); - } catch (e) { - print(e); - } - - try { - await supabase - .from("mock-table") - .update({'user': 'picklerick', 'secret': 'whoops'}).eq("id", 42); - } catch (e) { - print(e); - } - - expect(fixture.mockHub.addBreadcrumbCalls.length, 3); - final inserted = fixture.mockHub.addBreadcrumbCalls[0].$1; - expect(inserted.data?['body'], - {'user': 'picklerick', 'password': '', 'null-me': null}); - - final upserted = fixture.mockHub.addBreadcrumbCalls[1].$1; - expect(upserted.data?['body'], {'user': 'picklerick', 'token': ''}); - - final updated = fixture.mockHub.addBreadcrumbCalls[2].$1; - expect( - updated.data?['body'], {'user': 'picklerick', 'secret': ''}); - }); - }); - - group('Tracing', () { - void verifyStartTransaction(String operation) { - expect(fixture.mockHub.startTransactionCalls.length, 1); - final startTransactionCalls = fixture.mockHub.startTransactionCalls.first; - expect(startTransactionCalls.$1, 'from(mock-table)'); // name - expect(startTransactionCalls.$2, 'db.$operation'); - } - - void verifyCommonSpanAttributes(String version) { - final span = fixture.mockHub.mockSpan; - 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['origin'], 'auto.db.supabase'); - } - - void verifyFinishSpan() { - final span = fixture.mockHub.mockSpan; - expect(span.finishCalls.length, 1); - final finishCall = span.finishCalls.first; - expect(finishCall.$1, SpanStatus.ok()); - } - - test('should not create trace if disabled', () async { - fixture.options.tracesSampleRate = null; - - final supabase = fixture.getSupabaseClient(breadcrumbs: false); - - try { - await supabase.from('mock-table').select(); - } catch (e) { - print(e); - } - - expect(fixture.mockHub.startTransactionCalls.length, 0); - }); - - test('should create trace for select', () async { - fixture.mockClient.jsonResponse = '{"id": 42}'; - - final supabase = fixture.getSupabaseClient(breadcrumbs: false); - - try { - await supabase - .from("mock-table") - .select() - .lt("id", 42) - .gt("id", 20) - .not("id", "eq", 32); - } catch (e) { - print(e); - } - - verifyStartTransaction('select'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); - verifyFinishSpan(); - - final span = fixture.mockHub.mockSpan; - expect(span.data['db.query'], [ - "select(*)", - "lt(id, 42)", - "gt(id, 20)", - "not(id, eq.32)", - ]); - expect(span.data['op'], 'db.select'); - }); - - test('should create trace for insert', () async { - fixture.mockClient.jsonResponse = '{"id": 42}'; - - final supabase = fixture.getSupabaseClient(breadcrumbs: false); - - try { - await supabase.from("mock-table").insert({"id": 42}); - } catch (e) { - print(e); - } - - verifyStartTransaction('insert'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); - verifyFinishSpan(); - - final span = fixture.mockHub.mockSpan; - expect(span.data['db.body'], {'id': 42}); - expect(span.data['op'], 'db.insert'); - }); - - test('should create trace for upsert', () async { - fixture.mockClient.jsonResponse = '{"id": 42}'; - - final supabase = fixture.getSupabaseClient(breadcrumbs: false); - - try { - await supabase.from("mock-table").upsert({"id": 42}).select("id,name"); - } catch (e) { - print(e); - } - - verifyStartTransaction('upsert'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); - verifyFinishSpan(); - - final span = fixture.mockHub.mockSpan; - expect(span.data['db.body'], {'id': 42}); - expect(span.data['db.query'], ['select(id,name)']); - expect(span.data['op'], 'db.upsert'); - }); - - test('should create trace for update', () async { - fixture.mockClient.jsonResponse = '{"id": 1337}'; - - final supabase = fixture.getSupabaseClient(breadcrumbs: false); - - try { - await supabase - .from("mock-table") - .update({"id": 1337}) - .eq("id", 42) - .or("id.eq.8"); - } catch (e) { - print(e); - } - - verifyStartTransaction('update'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); - verifyFinishSpan(); - - final span = fixture.mockHub.mockSpan; - expect(span.data['db.body'], {'id': 1337}); - expect(span.data['db.query'], ["eq(id, 42)", "or(id.eq.8)"]); - expect(span.data['op'], 'db.update'); - }); - - test('should create trace for delete', () async { - fixture.mockClient.jsonResponse = '{}'; - - final supabase = fixture.getSupabaseClient(breadcrumbs: false); - - try { - await supabase.from("mock-table").delete().eq("id", 42); - } catch (e) { - print(e); - } - - verifyStartTransaction('delete'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); - verifyFinishSpan(); - - final span = fixture.mockHub.mockSpan; - expect(span.data['db.query'], ["eq(id, 42)"]); - expect(span.data['op'], 'db.delete'); - }); - - test('should finish with error status if request fails', () async { - fixture.mockClient.statusCode = 404; - - final supabase = fixture.getSupabaseClient(breadcrumbs: false); - - try { - await supabase.from("mock-table").delete().eq("id", 42); - } catch (e) { - print(e); - } - - final span = fixture.mockHub.mockSpan; - expect(span.finishCalls.length, 1); - final finishCall = span.finishCalls.first; - expect(finishCall.$1, SpanStatus.fromHttpStatusCode(404)); - }); - }); -} - -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.tracesSampleRate = 1.0; // enable tracing - } - - SentrySupabaseClient getSut({ - bool breadcrumbs = true, - SentrySupabaseRedactRequestBody? redactRequestBody, - }) { - return SentrySupabaseClient( - breadcrumbs: breadcrumbs, - client: mockClient, - hub: mockHub, - redactRequestBody: redactRequestBody, - ); - } - - SupabaseClient getSupabaseClient({ - bool breadcrumbs = true, - SentrySupabaseRedactRequestBody? redactRequestBody, - }) { - return SupabaseClient( - supabaseUrl, - supabaseKey, - httpClient: getSut( - breadcrumbs: breadcrumbs, - redactRequestBody: redactRequestBody, - ), - ); - } -} - -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); - } -} - -class MockHub implements Hub { - MockHub(this._options); - final SentryOptions _options; - - @override - SentryOptions get options => _options; - - final addBreadcrumbCalls = <(Breadcrumb, Hint?)>[]; - final startTransactionCalls = <(String, String)>[]; - - @override - Future addBreadcrumb(Breadcrumb crumb, {Hint? hint}) async { - addBreadcrumbCalls.add((crumb, hint)); - } - - var mockSpan = 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; - } - - // 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?)>[]; - - @override - void setData(String key, dynamic value) { - data[key] = value; - } - - @override - Future finish( - {SpanStatus? status, DateTime? endTimestamp, Hint? hint}) { - finishCalls.add((status, endTimestamp, hint)); - return Future.value(); - } - - // No such method - @override - void noSuchMethod(Invocation invocation) { - 'Method ${invocation.memberName} was called ' - 'with arguments ${invocation.positionalArguments}'; - } -} 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..61faa8e5bc --- /dev/null +++ b/supabase/test/sentry_supabase_error_client_test.dart @@ -0,0 +1,289 @@ +import 'package:sentry_supabase/sentry_supabase.dart'; +import 'package:sentry_supabase/src/sentry_supabase_error_client.dart'; +import 'package:test/test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:http/http.dart'; +import 'dart:convert'; + +import 'dart:async'; + +import 'package:supabase/supabase.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) { + // Do nothing + } + + 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) { + // Do nothing + } + + 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) { + // Do nothing + } + + 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) { + // Do nothing + } + + 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) { + // Do nothing + } + + 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) { + // Do nothing + } + + 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)"]); + }); + }); +} + +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.tracesSampleRate = 1.0; // enable tracing + } + + SentrySupabaseErrorClient getSut() { + return SentrySupabaseErrorClient( + mockClient, + mockHub, + ); + } + + SupabaseClient getSupabaseClient() { + return SupabaseClient( + supabaseUrl, + 'YOUR_ANON_KEY', + httpClient: getSut(), + ); + } +} + +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); + } +} + +class _MockHub implements Hub { + _MockHub(this._options); + final SentryOptions _options; + + @override + SentryOptions get options => _options; + + 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}'; + } +} 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..1e6d163e83 --- /dev/null +++ b/supabase/test/sentry_supabase_tracing_client_test.dart @@ -0,0 +1,343 @@ +import 'package:sentry_supabase/sentry_supabase.dart'; +import 'package:test/test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:http/http.dart'; +import 'dart:convert'; + +import 'dart:async'; + +import 'package:supabase/supabase.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('Tracing', () { + void verifyStartTransaction(String operation) { + expect(fixture.mockHub.startTransactionCalls.length, 1); + final startTransactionCalls = fixture.mockHub.startTransactionCalls.first; + expect(startTransactionCalls.$1, 'from(mock-table)'); // name + expect(startTransactionCalls.$2, 'db.$operation'); + } + + void verifyCommonSpanAttributes(String version) { + final span = fixture.mockHub.mockSpan; + 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['origin'], 'auto.db.supabase'); + } + + void verifyFinishSpan() { + final span = fixture.mockHub.mockSpan; + 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); + } catch (e) { + print(e); + } + + verifyStartTransaction('select'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.query'], [ + "select(*)", + "lt(id, 42)", + "gt(id, 20)", + "not(id, eq.32)", + ]); + expect(span.data['op'], 'db.select'); + }); + + 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) { + print(e); + } + + verifyStartTransaction('insert'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.body'], {'id': 42}); + expect(span.data['op'], 'db.insert'); + }); + + 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) { + print(e); + } + + verifyStartTransaction('upsert'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.body'], {'id': 42}); + expect(span.data['db.query'], ['select(id,name)']); + expect(span.data['op'], 'db.upsert'); + }); + + 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) { + print(e); + } + + verifyStartTransaction('update'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.body'], {'id': 1337}); + expect(span.data['db.query'], ["eq(id, 42)", "or(id.eq.8)"]); + expect(span.data['op'], 'db.update'); + }); + + 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) { + print(e); + } + + verifyStartTransaction('delete'); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyFinishSpan(); + + final span = fixture.mockHub.mockSpan; + expect(span.data['db.query'], ["eq(id, 42)"]); + expect(span.data['op'], 'db.delete'); + }); + + 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) { + print(e); + } + + final span = fixture.mockHub.mockSpan; + 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.mockSpan; + expect(span.finishCalls.length, 1); + + final setThrowableCall = span.setThrowableCalls.first; + expect(setThrowableCall, exception); + + final setStatusCall = span.setStatusCalls.first; + expect(setStatusCall, SpanStatus.internalError()); + }); + }); +} + +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); + + SentrySupabaseTracingClient getSut() { + return SentrySupabaseTracingClient(mockClient, mockHub); + } + + SupabaseClient getSupabaseClient() { + return SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: getSut(), + ); + } +} + +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); + } +} + +class _MockHub implements Hub { + _MockHub(this._options); + + final SentryOptions _options; + + @override + SentryOptions get options => _options; + + final startTransactionCalls = <(String, String)>[]; + + var mockSpan = _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; + } + + // 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 = []; + + @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(); + } + + // No such method + @override + void noSuchMethod(Invocation invocation) { + 'Method ${invocation.memberName} was called ' + 'with arguments ${invocation.positionalArguments}'; + } +} From 4c8acccf69276a55a91a6c951a34755d01c4b889 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 11:55:15 +0200 Subject: [PATCH 17/32] add SentrySupabaseClient --- supabase/lib/src/sentry_supabase_client.dart | 54 ++++++++ supabase/test/mock_client.dart | 28 ++++ supabase/test/mock_hub.dart | 100 ++++++++++++++ ...entry_supabase_breadcrumb_client_test.dart | 25 +--- .../test/sentry_supabase_client_test.dart | 128 ++++++++++++++++++ .../sentry_supabase_error_client_test.dart | 61 +-------- .../sentry_supabase_tracing_client_test.dart | 111 +-------------- 7 files changed, 320 insertions(+), 187 deletions(-) create mode 100644 supabase/test/mock_client.dart create mode 100644 supabase/test/mock_hub.dart create mode 100644 supabase/test/sentry_supabase_client_test.dart diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 8b13789179..59c866b9fe 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -1 +1,55 @@ +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'; + +class SentrySupabaseClient extends BaseClient { + final bool _breadcrumbs; + final bool _tracing; + final bool _errors; + + Client _innerClient; + final Hub _hub; + + SentrySupabaseClient({ + required bool breadcrumbs, + required bool tracing, + required bool errors, + required Client client, + Hub? hub, + }) : _breadcrumbs = breadcrumbs, + _tracing = tracing, + _errors = errors, + _innerClient = client, + _hub = hub ?? HubAdapter(); + + @override + Future send(BaseRequest request) async { + if (_breadcrumbs) { + _innerClient = SentrySupabaseBreadcrumbClient( + _innerClient, + _hub, + ); + } + if (_tracing) { + _innerClient = SentrySupabaseTracingClient( + _innerClient, + _hub, + ); + } + if (_errors) { + _innerClient = SentrySupabaseErrorClient( + _innerClient, + _hub, + ); + } + return _innerClient.send(request); + } + + @override + void close() { + _innerClient.close(); + } +} diff --git a/supabase/test/mock_client.dart b/supabase/test/mock_client.dart new file mode 100644 index 0000000000..9303f6b489 --- /dev/null +++ b/supabase/test/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/mock_hub.dart b/supabase/test/mock_hub.dart new file mode 100644 index 0000000000..b953506114 --- /dev/null +++ b/supabase/test/mock_hub.dart @@ -0,0 +1,100 @@ +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(); + + @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; + } + + // 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 = []; + + @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(); + } + + // 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 index b3ced0dad4..66c40ccbb9 100644 --- a/supabase/test/sentry_supabase_breadcrumb_client_test.dart +++ b/supabase/test/sentry_supabase_breadcrumb_client_test.dart @@ -7,6 +7,7 @@ import 'package:sentry/sentry.dart'; import 'package:http/http.dart'; import 'package:supabase/supabase.dart'; +import 'mock_hub.dart'; void main() { late Fixture fixture; @@ -134,7 +135,7 @@ class Fixture { dsn: 'https://example.com/123', ); final mockClient = _MockClient(); - late final mockHub = _MockHub(options); + late final mockHub = MockHub(options); Fixture() { options.tracesSampleRate = 1.0; // enable tracing @@ -177,25 +178,3 @@ class _MockClient extends BaseClient { closeCalls.add(null); } } - -class _MockHub implements Hub { - _MockHub(this._options); - final SentryOptions _options; - - @override - SentryOptions get options => _options; - - final addBreadcrumbCalls = <(Breadcrumb, Hint?)>[]; - - @override - Future addBreadcrumb(Breadcrumb crumb, {Hint? hint}) async { - addBreadcrumbCalls.add((crumb, hint)); - } - - // No such method - @override - void noSuchMethod(Invocation invocation) { - 'Method ${invocation.memberName} was called ' - 'with arguments ${invocation.positionalArguments}'; - } -} diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart new file mode 100644 index 0000000000..f6ec0e99a5 --- /dev/null +++ b/supabase/test/sentry_supabase_client_test.dart @@ -0,0 +1,128 @@ +import 'package:sentry_supabase/sentry_supabase.dart'; +import 'package:test/test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:http/http.dart'; + +import 'mock_client.dart'; +import 'mock_hub.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('Inner Client', () { + test('send called on send', () async { + final sut = fixture.getSut( + breadcrumbs: true, + tracing: true, + errors: 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( + breadcrumbs: true, + tracing: true, + errors: true, + ); + + sut.close(); + + expect(fixture.mockClient.closeCalls.length, 1); + }); + }); + + group('Inner Sentry Supabase Clients', () { + test('breadcrumb client', () async { + final sut = fixture.getSut( + breadcrumbs: true, + tracing: false, + errors: false, + ); + + final request = Request('GET', Uri.parse('https://example.com/123')); + await sut.send(request); + + expect(fixture.mockHub.addBreadcrumbCalls.length, 1); + }); + + test('tracing client', () async { + final sut = fixture.getSut( + breadcrumbs: false, + tracing: true, + errors: false, + ); + + final request = Request('GET', Uri.parse('https://example.com/123')); + await sut.send(request); + + expect(fixture.mockHub.startTransactionCalls.length, 1); + }); + + test('error client', () async { + final sut = fixture.getSut( + breadcrumbs: false, + tracing: false, + errors: true, + ); + + fixture.mockClient.statusCode = 404; + + final request = Request('GET', Uri.parse('https://example.com/123')); + await sut.send(request); + + expect(fixture.mockHub.captureEventCalls.length, 1); + }); + + test('all clients', () async { + final sut = fixture.getSut( + breadcrumbs: true, + tracing: true, + errors: true, + ); + + fixture.mockClient.statusCode = 404; + + final request = Request('GET', Uri.parse('https://example.com/123')); + await sut.send(request); + + expect(fixture.mockHub.addBreadcrumbCalls.length, 1); + expect(fixture.mockHub.startTransactionCalls.length, 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 breadcrumbs, + required bool tracing, + required bool errors, + }) { + return SentrySupabaseClient( + breadcrumbs: breadcrumbs, + tracing: tracing, + errors: errors, + client: mockClient, + hub: mockHub, + ); + } +} diff --git a/supabase/test/sentry_supabase_error_client_test.dart b/supabase/test/sentry_supabase_error_client_test.dart index 61faa8e5bc..adaea79afd 100644 --- a/supabase/test/sentry_supabase_error_client_test.dart +++ b/supabase/test/sentry_supabase_error_client_test.dart @@ -3,11 +3,10 @@ import 'package:sentry_supabase/src/sentry_supabase_error_client.dart'; import 'package:test/test.dart'; import 'package:sentry/sentry.dart'; import 'package:http/http.dart'; -import 'dart:convert'; - -import 'dart:async'; import 'package:supabase/supabase.dart'; +import 'mock_client.dart'; +import 'mock_hub.dart'; void main() { late Fixture fixture; @@ -212,7 +211,7 @@ class Fixture { dsn: 'https://example.com/123', ); final mockClient = MockClient(); - late final mockHub = _MockHub(options); + late final mockHub = MockHub(options); Fixture() { options.tracesSampleRate = 1.0; // enable tracing @@ -233,57 +232,3 @@ class Fixture { ); } } - -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); - } -} - -class _MockHub implements Hub { - _MockHub(this._options); - final SentryOptions _options; - - @override - SentryOptions get options => _options; - - 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}'; - } -} diff --git a/supabase/test/sentry_supabase_tracing_client_test.dart b/supabase/test/sentry_supabase_tracing_client_test.dart index 1e6d163e83..6ccb9007fa 100644 --- a/supabase/test/sentry_supabase_tracing_client_test.dart +++ b/supabase/test/sentry_supabase_tracing_client_test.dart @@ -1,12 +1,11 @@ -import 'package:sentry_supabase/sentry_supabase.dart'; +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 'dart:convert'; - -import 'dart:async'; import 'package:supabase/supabase.dart'; +import 'mock_client.dart'; +import 'mock_hub.dart'; void main() { late Fixture fixture; @@ -226,8 +225,8 @@ class Fixture { final options = SentryOptions( dsn: 'https://example.com/123', ); - final mockClient = _MockClient(); - late final mockHub = _MockHub(options); + final mockClient = MockClient(); + late final mockHub = MockHub(options); SentrySupabaseTracingClient getSut() { return SentrySupabaseTracingClient(mockClient, mockHub); @@ -241,103 +240,3 @@ class Fixture { ); } } - -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); - } -} - -class _MockHub implements Hub { - _MockHub(this._options); - - final SentryOptions _options; - - @override - SentryOptions get options => _options; - - final startTransactionCalls = <(String, String)>[]; - - var mockSpan = _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; - } - - // 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 = []; - - @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(); - } - - // No such method - @override - void noSuchMethod(Invocation invocation) { - 'Method ${invocation.memberName} was called ' - 'with arguments ${invocation.positionalArguments}'; - } -} From 63a5c676a29aa0b67ccc6efe588e613981dcd7af Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 14:22:08 +0200 Subject: [PATCH 18/32] update --- supabase/lib/src/sentry_supabase_client.dart | 63 ++++++++++++++----- ...entry_supabase_breadcrumb_client_test.dart | 25 ++++++-- .../sentry_supabase_error_client_test.dart | 12 ++-- .../sentry_supabase_tracing_client_test.dart | 12 ++-- 4 files changed, 78 insertions(+), 34 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 59c866b9fe..50c96a3b87 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -5,6 +5,44 @@ 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( +/// breadcrumbs: false, +/// tracing: false, +/// errors: 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()), +/// ); +/// ``` class SentrySupabaseClient extends BaseClient { final bool _breadcrumbs; final bool _tracing; @@ -14,36 +52,27 @@ class SentrySupabaseClient extends BaseClient { final Hub _hub; SentrySupabaseClient({ - required bool breadcrumbs, - required bool tracing, - required bool errors, - required Client client, + bool breadcrumbs = true, + bool tracing = true, + bool errors = true, + Client? client, Hub? hub, }) : _breadcrumbs = breadcrumbs, _tracing = tracing, _errors = errors, - _innerClient = client, + _innerClient = client ?? Client(), _hub = hub ?? HubAdapter(); @override Future send(BaseRequest request) async { if (_breadcrumbs) { - _innerClient = SentrySupabaseBreadcrumbClient( - _innerClient, - _hub, - ); + _innerClient = SentrySupabaseBreadcrumbClient(_innerClient, _hub); } if (_tracing) { - _innerClient = SentrySupabaseTracingClient( - _innerClient, - _hub, - ); + _innerClient = SentrySupabaseTracingClient(_innerClient, _hub); } if (_errors) { - _innerClient = SentrySupabaseErrorClient( - _innerClient, - _hub, - ); + _innerClient = SentrySupabaseErrorClient(_innerClient, _hub); } return _innerClient.send(request); } diff --git a/supabase/test/sentry_supabase_breadcrumb_client_test.dart b/supabase/test/sentry_supabase_breadcrumb_client_test.dart index 66c40ccbb9..3a4000bf4d 100644 --- a/supabase/test/sentry_supabase_breadcrumb_client_test.dart +++ b/supabase/test/sentry_supabase_breadcrumb_client_test.dart @@ -44,7 +44,7 @@ void main() { try { await supabase.from('countries').select().eq('id', 42); } catch (e) { - print(e); + // Ignore } expect(fixture.mockHub.addBreadcrumbCalls.length, 1); @@ -52,6 +52,9 @@ void main() { 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)']); }); @@ -61,7 +64,7 @@ void main() { try { await supabase.from('countries').insert({'id': 42}); } catch (e) { - print(e); + // Ignore } expect(fixture.mockHub.addBreadcrumbCalls.length, 1); @@ -69,6 +72,9 @@ void main() { 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}); }); @@ -78,7 +84,7 @@ void main() { try { await supabase.from('countries').upsert({'id': 42}).select(); } catch (e) { - print(e); + // Ignore } expect(fixture.mockHub.addBreadcrumbCalls.length, 1); @@ -86,6 +92,9 @@ void main() { 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}); }); @@ -96,7 +105,7 @@ void main() { try { await supabase.from('countries').update({'id': 1337}).eq('id', 42); } catch (e) { - print(e); + // Ignore } expect(fixture.mockHub.addBreadcrumbCalls.length, 1); @@ -104,6 +113,9 @@ void main() { 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}); }); @@ -114,7 +126,7 @@ void main() { try { await supabase.from('countries').delete().eq('id', 42); } catch (e) { - print(e); + // Ignore } expect(fixture.mockHub.addBreadcrumbCalls.length, 1); @@ -122,6 +134,9 @@ void main() { 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)']); }); }); diff --git a/supabase/test/sentry_supabase_error_client_test.dart b/supabase/test/sentry_supabase_error_client_test.dart index adaea79afd..b2663129f9 100644 --- a/supabase/test/sentry_supabase_error_client_test.dart +++ b/supabase/test/sentry_supabase_error_client_test.dart @@ -45,7 +45,7 @@ void main() { try { await supabase.from("mock-table").select().eq("id", 42); } catch (e) { - // Do nothing + // Ignore } expect(fixture.mockHub.captureEventCalls.length, 1); @@ -94,7 +94,7 @@ void main() { try { await supabase.from("mock-table").select().eq("id", 42); } catch (e) { - // Do nothing + // Ignore } expect(fixture.mockHub.captureEventCalls.length, 1); @@ -117,7 +117,7 @@ void main() { try { await supabase.from("mock-table").insert({'id': 42}); } catch (e) { - // Do nothing + // Ignore } expect(fixture.mockHub.captureEventCalls.length, 1); @@ -140,7 +140,7 @@ void main() { try { await supabase.from("mock-table").update({'id': 1337}).eq("id", 42); } catch (e) { - // Do nothing + // Ignore } expect(fixture.mockHub.captureEventCalls.length, 1); @@ -164,7 +164,7 @@ void main() { try { await supabase.from("mock-table").upsert({'id': 42}).select(); } catch (e) { - // Do nothing + // Ignore } expect(fixture.mockHub.captureEventCalls.length, 1); @@ -188,7 +188,7 @@ void main() { try { await supabase.from("mock-table").delete().eq("id", 42); } catch (e) { - // Do nothing + // Ignore } expect(fixture.mockHub.captureEventCalls.length, 1); diff --git a/supabase/test/sentry_supabase_tracing_client_test.dart b/supabase/test/sentry_supabase_tracing_client_test.dart index 6ccb9007fa..986ef6bd95 100644 --- a/supabase/test/sentry_supabase_tracing_client_test.dart +++ b/supabase/test/sentry_supabase_tracing_client_test.dart @@ -72,7 +72,7 @@ void main() { .gt("id", 20) .not("id", "eq", 32); } catch (e) { - print(e); + // Ignore } verifyStartTransaction('select'); @@ -97,7 +97,7 @@ void main() { try { await supabase.from("mock-table").insert({"id": 42}); } catch (e) { - print(e); + // Ignore } verifyStartTransaction('insert'); @@ -117,7 +117,7 @@ void main() { try { await supabase.from("mock-table").upsert({"id": 42}).select("id,name"); } catch (e) { - print(e); + // Ignore } verifyStartTransaction('upsert'); @@ -142,7 +142,7 @@ void main() { .eq("id", 42) .or("id.eq.8"); } catch (e) { - print(e); + // Ignore } verifyStartTransaction('update'); @@ -163,7 +163,7 @@ void main() { try { await supabase.from("mock-table").delete().eq("id", 42); } catch (e) { - print(e); + // Ignore } verifyStartTransaction('delete'); @@ -183,7 +183,7 @@ void main() { try { await supabase.from("mock-table").delete().eq("id", 42); } catch (e) { - print(e); + // Ignore } final span = fixture.mockHub.mockSpan; From 24bed611a5a7b7bdabe996dd3392bba33e276216 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 14:35:20 +0200 Subject: [PATCH 19/32] only export client --- supabase/lib/sentry_supabase.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/supabase/lib/sentry_supabase.dart b/supabase/lib/sentry_supabase.dart index 8e85dfab10..eb796b2e8e 100644 --- a/supabase/lib/sentry_supabase.dart +++ b/supabase/lib/sentry_supabase.dart @@ -1,4 +1,3 @@ library; export 'src/sentry_supabase_client.dart'; -export 'src/sentry_supabase_client_error.dart'; From cb7f1f59d4cccff40def49091e218b3d92fada2f Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 14:40:11 +0200 Subject: [PATCH 20/32] update example --- supabase/example/supabase_example.dart | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/supabase/example/supabase_example.dart b/supabase/example/supabase_example.dart index 2ac3227c0d..f60a2f9004 100644 --- a/supabase/example/supabase_example.dart +++ b/supabase/example/supabase_example.dart @@ -1,6 +1,18 @@ -import 'package:supabase/sentry_supabase.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:sentry_supabase/sentry_supabase.dart'; -void main() { - final client = SentrySupabaseClient(); - // TODO: Add supabase instumentation sample -} +// 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(); From 6b5ddc79eb17234d85ccf44e554a73874aea1ae7 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:05:42 +0200 Subject: [PATCH 21/32] update github actions and scripts --- .craft.yml | 5 ++- .github/workflows/diagrams.yml | 4 +++ .github/workflows/supabase.yml | 56 ++++++++++++++++++++++++++++++++++ scripts/bump-version.sh | 2 +- supabase/analysis_options.yaml | 56 ++++++++++++++++++---------------- supabase/pubspec.yaml | 6 ++-- 6 files changed, 97 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/supabase.yml diff --git a/.craft.yml b/.craft.yml index 19249c5ef9..110b492c3c 100644 --- a/.craft.yml +++ b/.craft.yml @@ -19,6 +19,7 @@ targets: isar: link: firebase_remote_config: + supabase: - name: github - name: registry sdks: @@ -34,4 +35,6 @@ targets: # TODO: after we published link we need to add it to the registry repo and then uncomment here # pub:sentry_link: # TODO: after we published firebase we need to add it to the registry repo and then uncomment here - # pub:sentry_firebase_remote_config: \ No newline at end of file + # 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/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/analysis_options.yaml b/supabase/analysis_options.yaml index dee8927aaf..7119dc352d 100644 --- a/supabase/analysis_options.yaml +++ b/supabase/analysis_options.yaml @@ -1,30 +1,32 @@ -# This file configures the static analysis results for your project (errors, -# warnings, and lints). -# -# This enables the 'recommended' set of lints from `package:lints`. -# This set helps identify many issues that may lead to problems when running -# or consuming Dart code, and enforces writing Dart using a single, idiomatic -# style and format. -# -# If you want a smaller set of lints you can change this to specify -# 'package:lints/core.yaml'. These are just the most critical lints -# (the recommended set includes the core lints). -# The core lints are also what is used by pub.dev for scoring packages. - include: package:lints/recommended.yaml -# Uncomment the following section to specify additional rules. - -# linter: -# rules: -# - camel_case_types - -# analyzer: -# exclude: -# - path/to/excluded/files/** - -# For more information about the core and recommended set of lints, see -# https://dart.dev/go/core-lints +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 -# For additional information about configuring this file, see -# https://dart.dev/guides/language/analysis-options +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/pubspec.yaml b/supabase/pubspec.yaml index c1bf951208..ec483b6709 100644 --- a/supabase/pubspec.yaml +++ b/supabase/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_supabase -description: "Sentry integration to use instument Supabase." -version: 9.0.0-alpha.2 +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 @@ -10,7 +10,7 @@ environment: dependencies: http: ^1.3.0 - sentry: 9.0.0-alpha.2 + sentry: 9.0.0-RC.3 dev_dependencies: supabase: ^2.6.0 From 2d5495b2f22b117b398dde62f958bdfde1073b0f Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:17:27 +0200 Subject: [PATCH 22/32] run fix & format --- supabase/lib/src/sentry_supabase_request.dart | 104 +++++++++--------- .../src/sentry_supabase_tracing_client.dart | 10 +- supabase/test/mock_hub.dart | 7 +- .../sentry_supabase_error_client_test.dart | 23 ++-- .../sentry_supabase_tracing_client_test.dart | 48 ++++---- 5 files changed, 99 insertions(+), 93 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_request.dart b/supabase/lib/src/sentry_supabase_request.dart index 3120802752..1f2f74975d 100644 --- a/supabase/lib/src/sentry_supabase_request.dart +++ b/supabase/lib/src/sentry_supabase_request.dart @@ -36,19 +36,21 @@ class SentrySupabaseRequest { } static Operation _extractOperation( - String method, Map headers) { + String method, + Map headers, + ) { switch (method) { - case "GET": + case 'GET': return Operation.select; - case "POST": - if (headers["Prefer"]?.contains("resolution=") ?? false) { + case 'POST': + if (headers['Prefer']?.contains('resolution=') ?? false) { return Operation.upsert; } else { return Operation.insert; } - case "PATCH": + case 'PATCH': return Operation.update; - case "DELETE": + case 'DELETE': return Operation.delete; default: return Operation.select; // Should never happen. @@ -68,7 +70,7 @@ class SentrySupabaseRequest { static Map? _readBody(String table, BaseRequest request) { final bodyString = request is Request && request.body.isNotEmpty ? request.body : null; - var body = bodyString != null ? jsonDecode(bodyString) : null; + final body = bodyString != null ? jsonDecode(bodyString) : null; // if (body != null && _redactRequestBody != null) { // for (final entry in body.entries) { @@ -79,65 +81,65 @@ class SentrySupabaseRequest { } 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", + '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 (query.isEmpty || query == '*') { + return 'select(*)'; } - if (key == "select") { - return "select($query)"; + if (key == 'select') { + return 'select($query)'; } - if (key == "or" || key.endsWith(".or")) { + if (key == 'or' || key.endsWith('.or')) { return "$key$query"; } - final parts = query.split("."); + final parts = query.split('.'); final filter = parts[0]; - final value = parts.sublist(1).join("."); + 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]"; + 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"; + method = _filterMappings[filter] ?? 'filter'; } - return "$method($key, $value)"; + return '$method($key, $value)'; } } diff --git a/supabase/lib/src/sentry_supabase_tracing_client.dart b/supabase/lib/src/sentry_supabase_tracing_client.dart index 163d80f107..5afdff7f22 100644 --- a/supabase/lib/src/sentry_supabase_tracing_client.dart +++ b/supabase/lib/src/sentry_supabase_tracing_client.dart @@ -45,16 +45,18 @@ class SentrySupabaseTracingClient extends BaseClient { final description = 'from(${supabaseRequest.table})'; final span = _hub.startTransaction( - description, 'db.${supabaseRequest.operation.value}'); + description, + 'db.${supabaseRequest.operation.value}', + ); - final dbSchema = supabaseRequest.request.headers["Accept-Profile"] ?? - supabaseRequest.request.headers["Content-Profile"]; + 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"]; + final dbSdk = supabaseRequest.request.headers['X-Client-Info']; if (dbSdk != null) { span.setData('db.sdk', dbSdk); } diff --git a/supabase/test/mock_hub.dart b/supabase/test/mock_hub.dart index b953506114..40a01564c1 100644 --- a/supabase/test/mock_hub.dart +++ b/supabase/test/mock_hub.dart @@ -85,8 +85,11 @@ class _MockSpan implements ISentrySpan { } @override - Future finish( - {SpanStatus? status, DateTime? endTimestamp, Hint? hint}) { + Future finish({ + SpanStatus? status, + DateTime? endTimestamp, + Hint? hint, + }) { finishCalls.add((status, endTimestamp, hint)); return Future.value(); } diff --git a/supabase/test/sentry_supabase_error_client_test.dart b/supabase/test/sentry_supabase_error_client_test.dart index b2663129f9..fc43a8db75 100644 --- a/supabase/test/sentry_supabase_error_client_test.dart +++ b/supabase/test/sentry_supabase_error_client_test.dart @@ -1,4 +1,3 @@ -import 'package:sentry_supabase/sentry_supabase.dart'; import 'package:sentry_supabase/src/sentry_supabase_error_client.dart'; import 'package:test/test.dart'; import 'package:sentry/sentry.dart'; @@ -43,7 +42,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").select().eq("id", 42); + await supabase.from('mock-table').select().eq('id', 42); } catch (e) { // Ignore } @@ -68,7 +67,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").select().eq("id", 42); + await supabase.from('mock-table').select().eq('id', 42); } catch (e) { expect(e, error); // Error is rethrown } @@ -92,7 +91,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").select().eq("id", 42); + await supabase.from('mock-table').select().eq('id', 42); } catch (e) { // Ignore } @@ -105,7 +104,7 @@ void main() { event.contexts['supabase'] as Map; expect(supabaseContext['table'], 'mock-table'); expect(supabaseContext['operation'], 'select'); - expect(supabaseContext['query'], ['select(*)', "eq(id, 42)"]); + expect(supabaseContext['query'], ['select(*)', 'eq(id, 42)']); }); test('should add supabase data to context if insert request fails', @@ -115,7 +114,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").insert({'id': 42}); + await supabase.from('mock-table').insert({'id': 42}); } catch (e) { // Ignore } @@ -138,7 +137,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").update({'id': 1337}).eq("id", 42); + await supabase.from('mock-table').update({'id': 1337}).eq('id', 42); } catch (e) { // Ignore } @@ -152,7 +151,7 @@ void main() { expect(supabaseContext['table'], 'mock-table'); expect(supabaseContext['operation'], 'update'); expect(supabaseContext['body'], {'id': 1337}); - expect(supabaseContext['query'], ["eq(id, 42)"]); + expect(supabaseContext['query'], ['eq(id, 42)']); }); test('should add supabase data to context if upsert request fails', @@ -162,7 +161,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").upsert({'id': 42}).select(); + await supabase.from('mock-table').upsert({'id': 42}).select(); } catch (e) { // Ignore } @@ -176,7 +175,7 @@ void main() { expect(supabaseContext['table'], 'mock-table'); expect(supabaseContext['operation'], 'upsert'); expect(supabaseContext['body'], {'id': 42}); - expect(supabaseContext['query'], ["select(*)"]); + expect(supabaseContext['query'], ['select(*)']); }); test('should add supabase data to context if delete request fails', @@ -186,7 +185,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").delete().eq("id", 42); + await supabase.from('mock-table').delete().eq('id', 42); } catch (e) { // Ignore } @@ -199,7 +198,7 @@ void main() { event.contexts['supabase'] as Map; expect(supabaseContext['table'], 'mock-table'); expect(supabaseContext['operation'], 'delete'); - expect(supabaseContext['query'], ["eq(id, 42)"]); + expect(supabaseContext['query'], ['eq(id, 42)']); }); }); } diff --git a/supabase/test/sentry_supabase_tracing_client_test.dart b/supabase/test/sentry_supabase_tracing_client_test.dart index 986ef6bd95..5a40686e3b 100644 --- a/supabase/test/sentry_supabase_tracing_client_test.dart +++ b/supabase/test/sentry_supabase_tracing_client_test.dart @@ -66,25 +66,25 @@ void main() { try { await supabase - .from("mock-table") + .from('mock-table') .select() - .lt("id", 42) - .gt("id", 20) - .not("id", "eq", 32); + .lt('id', 42) + .gt('id', 20) + .not('id', 'eq', 32); } catch (e) { // Ignore } verifyStartTransaction('select'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ''); verifyFinishSpan(); final span = fixture.mockHub.mockSpan; expect(span.data['db.query'], [ - "select(*)", - "lt(id, 42)", - "gt(id, 20)", - "not(id, eq.32)", + 'select(*)', + 'lt(id, 42)', + 'gt(id, 20)', + 'not(id, eq.32)', ]); expect(span.data['op'], 'db.select'); }); @@ -95,13 +95,13 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").insert({"id": 42}); + await supabase.from('mock-table').insert({'id': 42}); } catch (e) { // Ignore } verifyStartTransaction('insert'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ''); verifyFinishSpan(); final span = fixture.mockHub.mockSpan; @@ -115,13 +115,13 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").upsert({"id": 42}).select("id,name"); + await supabase.from('mock-table').upsert({'id': 42}).select('id,name'); } catch (e) { // Ignore } verifyStartTransaction('upsert'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ''); verifyFinishSpan(); final span = fixture.mockHub.mockSpan; @@ -137,21 +137,21 @@ void main() { try { await supabase - .from("mock-table") - .update({"id": 1337}) - .eq("id", 42) - .or("id.eq.8"); + .from('mock-table') + .update({'id': 1337}) + .eq('id', 42) + .or('id.eq.8'); } catch (e) { // Ignore } verifyStartTransaction('update'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ''); verifyFinishSpan(); final span = fixture.mockHub.mockSpan; expect(span.data['db.body'], {'id': 1337}); - expect(span.data['db.query'], ["eq(id, 42)", "or(id.eq.8)"]); + expect(span.data['db.query'], ['eq(id, 42)', 'or(id.eq.8)']); expect(span.data['op'], 'db.update'); }); @@ -161,17 +161,17 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").delete().eq("id", 42); + await supabase.from('mock-table').delete().eq('id', 42); } catch (e) { // Ignore } verifyStartTransaction('delete'); - verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ""); + verifyCommonSpanAttributes(supabase.headers['X-Client-Info'] ?? ''); verifyFinishSpan(); final span = fixture.mockHub.mockSpan; - expect(span.data['db.query'], ["eq(id, 42)"]); + expect(span.data['db.query'], ['eq(id, 42)']); expect(span.data['op'], 'db.delete'); }); @@ -181,7 +181,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").delete().eq("id", 42); + await supabase.from('mock-table').delete().eq('id', 42); } catch (e) { // Ignore } @@ -201,7 +201,7 @@ void main() { final supabase = fixture.getSupabaseClient(); try { - await supabase.from("mock-table").delete().eq("id", 42); + await supabase.from('mock-table').delete().eq('id', 42); } catch (e) { expect(e, exception); // Rethrows } From 5e3ca918ef7d66ae8d5a7d159caf0aa7e817820c Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:19:08 +0200 Subject: [PATCH 23/32] remove from sample app --- flutter/example/lib/main.dart | 17 +---------------- .../test/sentry_supabase_error_client_test.dart | 1 + 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 30098f5206..7e5feb607b 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -28,8 +28,6 @@ import 'auto_close_screen.dart'; import 'drift/connection/connection.dart'; import 'drift/database.dart'; import 'isar/user.dart'; -import 'package:supabase_flutter/supabase_flutter.dart' as supabase; -import 'package:sentry_supabase/sentry_supabase.dart'; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io const String exampleDsn = @@ -44,20 +42,7 @@ var _isIntegrationTest = false; final GlobalKey navigatorKey = GlobalKey(); Future main() async { - final sentrySupabaseClient = SentrySupabaseClient(); - - await supabase.Supabase.initialize( - url: '', - anonKey: '', - httpClient: sentrySupabaseClient, - ); - - final supabaseClient = supabase.Supabase.instance.client; - final issues = await supabaseClient - .from('issues') - .select(); - - print(issues); + await setupSentry( () => runApp( diff --git a/supabase/test/sentry_supabase_error_client_test.dart b/supabase/test/sentry_supabase_error_client_test.dart index fc43a8db75..4dec69779d 100644 --- a/supabase/test/sentry_supabase_error_client_test.dart +++ b/supabase/test/sentry_supabase_error_client_test.dart @@ -1,4 +1,5 @@ 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'; From dedb01b97cdf3d59c8139f29f6233c6e53a92699 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:20:51 +0200 Subject: [PATCH 24/32] move mocks to folder --- supabase/test/{ => mocks}/mock_client.dart | 0 supabase/test/{ => mocks}/mock_hub.dart | 0 supabase/test/sentry_supabase_breadcrumb_client_test.dart | 2 +- supabase/test/sentry_supabase_client_test.dart | 4 ++-- supabase/test/sentry_supabase_error_client_test.dart | 4 ++-- supabase/test/sentry_supabase_tracing_client_test.dart | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) rename supabase/test/{ => mocks}/mock_client.dart (100%) rename supabase/test/{ => mocks}/mock_hub.dart (100%) diff --git a/supabase/test/mock_client.dart b/supabase/test/mocks/mock_client.dart similarity index 100% rename from supabase/test/mock_client.dart rename to supabase/test/mocks/mock_client.dart diff --git a/supabase/test/mock_hub.dart b/supabase/test/mocks/mock_hub.dart similarity index 100% rename from supabase/test/mock_hub.dart rename to supabase/test/mocks/mock_hub.dart diff --git a/supabase/test/sentry_supabase_breadcrumb_client_test.dart b/supabase/test/sentry_supabase_breadcrumb_client_test.dart index 3a4000bf4d..c331070919 100644 --- a/supabase/test/sentry_supabase_breadcrumb_client_test.dart +++ b/supabase/test/sentry_supabase_breadcrumb_client_test.dart @@ -7,7 +7,7 @@ import 'package:sentry/sentry.dart'; import 'package:http/http.dart'; import 'package:supabase/supabase.dart'; -import 'mock_hub.dart'; +import 'mocks/mock_hub.dart'; void main() { late Fixture fixture; diff --git a/supabase/test/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart index f6ec0e99a5..62d961eed2 100644 --- a/supabase/test/sentry_supabase_client_test.dart +++ b/supabase/test/sentry_supabase_client_test.dart @@ -3,8 +3,8 @@ import 'package:test/test.dart'; import 'package:sentry/sentry.dart'; import 'package:http/http.dart'; -import 'mock_client.dart'; -import 'mock_hub.dart'; +import 'mocks/mock_client.dart'; +import 'mocks/mock_hub.dart'; void main() { late Fixture fixture; diff --git a/supabase/test/sentry_supabase_error_client_test.dart b/supabase/test/sentry_supabase_error_client_test.dart index 4dec69779d..1c68502a67 100644 --- a/supabase/test/sentry_supabase_error_client_test.dart +++ b/supabase/test/sentry_supabase_error_client_test.dart @@ -5,8 +5,8 @@ import 'package:sentry/sentry.dart'; import 'package:http/http.dart'; import 'package:supabase/supabase.dart'; -import 'mock_client.dart'; -import 'mock_hub.dart'; +import 'mocks/mock_client.dart'; +import 'mocks/mock_hub.dart'; void main() { late Fixture fixture; diff --git a/supabase/test/sentry_supabase_tracing_client_test.dart b/supabase/test/sentry_supabase_tracing_client_test.dart index 5a40686e3b..c049a85052 100644 --- a/supabase/test/sentry_supabase_tracing_client_test.dart +++ b/supabase/test/sentry_supabase_tracing_client_test.dart @@ -4,8 +4,8 @@ import 'package:sentry/sentry.dart'; import 'package:http/http.dart'; import 'package:supabase/supabase.dart'; -import 'mock_client.dart'; -import 'mock_hub.dart'; +import 'mocks/mock_client.dart'; +import 'mocks/mock_hub.dart'; void main() { late Fixture fixture; From c3cbf8aeca530233b6a3c3859d07c0a50bdbcbae Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:22:47 +0200 Subject: [PATCH 25/32] attribute https://github.com/supabase-community/sentry-integration-js --- supabase/lib/src/sentry_supabase_request.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/lib/src/sentry_supabase_request.dart b/supabase/lib/src/sentry_supabase_request.dart index 1f2f74975d..4796b0dbf4 100644 --- a/supabase/lib/src/sentry_supabase_request.dart +++ b/supabase/lib/src/sentry_supabase_request.dart @@ -3,6 +3,7 @@ import 'package:http/http.dart'; import 'operation.dart'; +/// Concepts based on https://github.com/supabase-community/sentry-integration-js class SentrySupabaseRequest { final BaseRequest request; From 8693820bb3e47b661fb015bbf2e064572f28b41f Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:48:24 +0200 Subject: [PATCH 26/32] =?UTF-8?q?don=E2=80=99t=20send=20request=20body=20p?= =?UTF-8?q?er=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sentry_supabase_breadcrumb_client.dart | 6 ++- .../lib/src/sentry_supabase_error_client.dart | 6 ++- supabase/lib/src/sentry_supabase_request.dart | 17 ++++--- .../src/sentry_supabase_tracing_client.dart | 3 +- ...entry_supabase_breadcrumb_client_test.dart | 35 ++++++++++++++- .../sentry_supabase_error_client_test.dart | 44 ++++++++++++++++++- .../sentry_supabase_tracing_client_test.dart | 36 +++++++++++++++ 7 files changed, 131 insertions(+), 16 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_breadcrumb_client.dart b/supabase/lib/src/sentry_supabase_breadcrumb_client.dart index 020334c579..d254f97cc1 100644 --- a/supabase/lib/src/sentry_supabase_breadcrumb_client.dart +++ b/supabase/lib/src/sentry_supabase_breadcrumb_client.dart @@ -27,11 +27,13 @@ class SentrySupabaseBreadcrumbClient extends BaseClient { if (supabaseRequest.query.isNotEmpty) { breadcrumb.data?['query'] = supabaseRequest.query; } - if (supabaseRequest.body != null) { + + // ignore: invalid_use_of_internal_member + if (supabaseRequest.body != null && _hub.options.sendDefaultPii) { breadcrumb.data?['body'] = supabaseRequest.body; } - _hub.addBreadcrumb(breadcrumb); + await _hub.addBreadcrumb(breadcrumb); return _innerClient.send(request); } diff --git a/supabase/lib/src/sentry_supabase_error_client.dart b/supabase/lib/src/sentry_supabase_error_client.dart index cd98842cdf..6f9c2fca94 100644 --- a/supabase/lib/src/sentry_supabase_error_client.dart +++ b/supabase/lib/src/sentry_supabase_error_client.dart @@ -46,7 +46,7 @@ class SentrySupabaseErrorClient extends BaseClient { } void _captureException( - Exception? exception, + dynamic exception, StackTrace? stackTrace, BaseRequest request, StreamedResponse? response, @@ -65,7 +65,9 @@ class SentrySupabaseErrorClient extends BaseClient { 'table': supabaseRequest.table, 'operation': supabaseRequest.operation.value, if (supabaseRequest.query.isNotEmpty) 'query': supabaseRequest.query, - if (supabaseRequest.body != null) 'body': supabaseRequest.body, + // 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 index 4796b0dbf4..93651dfbbf 100644 --- a/supabase/lib/src/sentry_supabase_request.dart +++ b/supabase/lib/src/sentry_supabase_request.dart @@ -24,8 +24,8 @@ class SentrySupabaseRequest { final url = request.url; final table = url.pathSegments.last; final operation = _extractOperation(request.method, request.headers); - final query = _readQuery(request); // TODO: PII - final body = _readBody(table, request); // TODO: PII + final query = _readQuery(request); + final body = _readBody(table, request); return SentrySupabaseRequest( request: request, @@ -73,12 +73,11 @@ class SentrySupabaseRequest { request is Request && request.body.isNotEmpty ? request.body : null; final body = bodyString != null ? jsonDecode(bodyString) : null; - // if (body != null && _redactRequestBody != null) { - // for (final entry in body.entries) { - // body[entry.key] = _redactRequestBody(table, entry.key, entry.value); - // } - // } - return body; + if (body is Map) { + return body; + } else { + return null; + } } static const Map _filterMappings = { @@ -121,7 +120,7 @@ class SentrySupabaseRequest { } if (key == 'or' || key.endsWith('.or')) { - return "$key$query"; + return '$key$query'; } final parts = query.split('.'); diff --git a/supabase/lib/src/sentry_supabase_tracing_client.dart b/supabase/lib/src/sentry_supabase_tracing_client.dart index 5afdff7f22..d5b79b080c 100644 --- a/supabase/lib/src/sentry_supabase_tracing_client.dart +++ b/supabase/lib/src/sentry_supabase_tracing_client.dart @@ -63,7 +63,8 @@ class SentrySupabaseTracingClient extends BaseClient { if (supabaseRequest.query.isNotEmpty) { span.setData('db.query', supabaseRequest.query); } - if (supabaseRequest.body != null) { + // ignore: invalid_use_of_internal_member + if (supabaseRequest.body != null && _hub.options.sendDefaultPii) { span.setData('db.body', supabaseRequest.body); } span.setData('op', 'db.${supabaseRequest.operation.value}'); diff --git a/supabase/test/sentry_supabase_breadcrumb_client_test.dart b/supabase/test/sentry_supabase_breadcrumb_client_test.dart index c331070919..aa569cc0fe 100644 --- a/supabase/test/sentry_supabase_breadcrumb_client_test.dart +++ b/supabase/test/sentry_supabase_breadcrumb_client_test.dart @@ -140,6 +140,39 @@ void main() { 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?['body'], isNull); + + final upsertBreadcrumb = fixture.mockHub.addBreadcrumbCalls[1].$1; + expect(upsertBreadcrumb.data?['body'], isNull); + + final updateBreadcrumb = fixture.mockHub.addBreadcrumbCalls[2].$1; + expect(updateBreadcrumb.data?['body'], isNull); + }); + }); } class Fixture { @@ -153,7 +186,7 @@ class Fixture { late final mockHub = MockHub(options); Fixture() { - options.tracesSampleRate = 1.0; // enable tracing + options.sendDefaultPii = true; // Send PII by default in test. } SentrySupabaseBreadcrumbClient getSut() { diff --git a/supabase/test/sentry_supabase_error_client_test.dart b/supabase/test/sentry_supabase_error_client_test.dart index 1c68502a67..6b005f6f7e 100644 --- a/supabase/test/sentry_supabase_error_client_test.dart +++ b/supabase/test/sentry_supabase_error_client_test.dart @@ -202,6 +202,48 @@ void main() { 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['body'], isNull); + + final upsertEvent = fixture.mockHub.captureEventCalls[1].$1; + final upsertSupabaseContext = + upsertEvent.contexts['supabase'] as Map; + expect(upsertSupabaseContext['body'], isNull); + + final updateEvent = fixture.mockHub.captureEventCalls[2].$1; + final updateSupabaseContext = + updateEvent.contexts['supabase'] as Map; + expect(updateSupabaseContext['body'], isNull); + }); + }); } class Fixture { @@ -214,7 +256,7 @@ class Fixture { late final mockHub = MockHub(options); Fixture() { - options.tracesSampleRate = 1.0; // enable tracing + options.sendDefaultPii = true; // Send PII by default in test. } SentrySupabaseErrorClient getSut() { diff --git a/supabase/test/sentry_supabase_tracing_client_test.dart b/supabase/test/sentry_supabase_tracing_client_test.dart index c049a85052..8c947bbd30 100644 --- a/supabase/test/sentry_supabase_tracing_client_test.dart +++ b/supabase/test/sentry_supabase_tracing_client_test.dart @@ -216,6 +216,38 @@ void main() { 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.mockSpan; + expect(insertSpan.data['db.body'], isNull); + + try { + await supabase.from('countries').upsert({'id': 42}).select(); + } catch (e) { + // Ignore + } + final upsertSpan = fixture.mockHub.mockSpan; + expect(upsertSpan.data['db.body'], isNull); + + try { + await supabase.from('countries').update({'id': 1337}).eq('id', 42); + } catch (e) { + // Ignore + } + final updateSpan = fixture.mockHub.mockSpan; + expect(updateSpan.data['db.body'], isNull); + }); + }); } class Fixture { @@ -228,6 +260,10 @@ class Fixture { final mockClient = MockClient(); late final mockHub = MockHub(options); + Fixture() { + options.sendDefaultPii = true; // Send PII by default in test. + } + SentrySupabaseTracingClient getSut() { return SentrySupabaseTracingClient(mockClient, mockHub); } From 94320da2fc4c80304353a3f5e3025c75260954af Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:49:29 +0200 Subject: [PATCH 27/32] add docs --- supabase/lib/src/sentry_supabase_client.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/supabase/lib/src/sentry_supabase_client.dart b/supabase/lib/src/sentry_supabase_client.dart index 50c96a3b87..d779ae96be 100644 --- a/supabase/lib/src/sentry_supabase_client.dart +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -43,6 +43,9 @@ import 'sentry_supabase_error_client.dart'; /// 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 _breadcrumbs; final bool _tracing; From b6a14af0a429b8fa968d11a082e13de9c61f6831 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:55:57 +0200 Subject: [PATCH 28/32] treat query as pii --- supabase/lib/src/sentry_supabase_breadcrumb_client.dart | 3 ++- supabase/lib/src/sentry_supabase_error_client.dart | 4 +++- supabase/lib/src/sentry_supabase_tracing_client.dart | 3 ++- supabase/test/sentry_supabase_breadcrumb_client_test.dart | 3 +++ supabase/test/sentry_supabase_error_client_test.dart | 3 +++ supabase/test/sentry_supabase_tracing_client_test.dart | 4 +++- 6 files changed, 16 insertions(+), 4 deletions(-) diff --git a/supabase/lib/src/sentry_supabase_breadcrumb_client.dart b/supabase/lib/src/sentry_supabase_breadcrumb_client.dart index d254f97cc1..0d426a88b9 100644 --- a/supabase/lib/src/sentry_supabase_breadcrumb_client.dart +++ b/supabase/lib/src/sentry_supabase_breadcrumb_client.dart @@ -24,7 +24,8 @@ class SentrySupabaseBreadcrumbClient extends BaseClient { breadcrumb.data?['table'] = supabaseRequest.table; breadcrumb.data?['operation'] = supabaseRequest.operation.value; - if (supabaseRequest.query.isNotEmpty) { + // ignore: invalid_use_of_internal_member + if (supabaseRequest.query.isNotEmpty && _hub.options.sendDefaultPii) { breadcrumb.data?['query'] = supabaseRequest.query; } diff --git a/supabase/lib/src/sentry_supabase_error_client.dart b/supabase/lib/src/sentry_supabase_error_client.dart index 6f9c2fca94..8ea1b31eee 100644 --- a/supabase/lib/src/sentry_supabase_error_client.dart +++ b/supabase/lib/src/sentry_supabase_error_client.dart @@ -64,7 +64,9 @@ class SentrySupabaseErrorClient extends BaseClient { event.contexts['supabase'] = { 'table': supabaseRequest.table, 'operation': supabaseRequest.operation.value, - if (supabaseRequest.query.isNotEmpty) 'query': supabaseRequest.query, + // 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, diff --git a/supabase/lib/src/sentry_supabase_tracing_client.dart b/supabase/lib/src/sentry_supabase_tracing_client.dart index d5b79b080c..aa0b743b01 100644 --- a/supabase/lib/src/sentry_supabase_tracing_client.dart +++ b/supabase/lib/src/sentry_supabase_tracing_client.dart @@ -60,7 +60,8 @@ class SentrySupabaseTracingClient extends BaseClient { if (dbSdk != null) { span.setData('db.sdk', dbSdk); } - if (supabaseRequest.query.isNotEmpty) { + // 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 diff --git a/supabase/test/sentry_supabase_breadcrumb_client_test.dart b/supabase/test/sentry_supabase_breadcrumb_client_test.dart index aa569cc0fe..ce528eac9a 100644 --- a/supabase/test/sentry_supabase_breadcrumb_client_test.dart +++ b/supabase/test/sentry_supabase_breadcrumb_client_test.dart @@ -164,12 +164,15 @@ void main() { } 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); }); }); diff --git a/supabase/test/sentry_supabase_error_client_test.dart b/supabase/test/sentry_supabase_error_client_test.dart index 6b005f6f7e..9303ef456a 100644 --- a/supabase/test/sentry_supabase_error_client_test.dart +++ b/supabase/test/sentry_supabase_error_client_test.dart @@ -231,16 +231,19 @@ void main() { 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); }); }); diff --git a/supabase/test/sentry_supabase_tracing_client_test.dart b/supabase/test/sentry_supabase_tracing_client_test.dart index 8c947bbd30..9ad310db3d 100644 --- a/supabase/test/sentry_supabase_tracing_client_test.dart +++ b/supabase/test/sentry_supabase_tracing_client_test.dart @@ -229,6 +229,7 @@ void main() { // Ignore } final insertSpan = fixture.mockHub.mockSpan; + expect(insertSpan.data['db.query'], isNull); expect(insertSpan.data['db.body'], isNull); try { @@ -238,7 +239,7 @@ void main() { } final upsertSpan = fixture.mockHub.mockSpan; 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) { @@ -246,6 +247,7 @@ void main() { } final updateSpan = fixture.mockHub.mockSpan; expect(updateSpan.data['db.body'], isNull); + expect(updateSpan.data['db.query'], isNull); }); }); } From c808725339076becdb38a6b045f0025995ec3483 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 15:57:55 +0200 Subject: [PATCH 29/32] add cl entry --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b9b6aedb..302e6ceab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Features + +- 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.0.0-RC.3 ### Features From a39e9b4edecc3e0b4c41863ffc2ed188679ffec2 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 16:00:13 +0200 Subject: [PATCH 30/32] remove supabase from flutter sample app --- flutter/example/pubspec.yaml | 2 -- flutter/example/pubspec_overrides.yaml | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index 65ffe9e185..fb03fa96bf 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -11,7 +11,6 @@ environment: dependencies: flutter: sdk: flutter - supabase_flutter: ^2.9.0 sentry: sentry_flutter: sentry_dio: @@ -21,7 +20,6 @@ dependencies: sentry_hive: sentry_drift: sentry_isar: - sentry_supabase: universal_platform: ^1.0.0 feedback: ^2.0.0 provider: ^6.0.0 diff --git a/flutter/example/pubspec_overrides.yaml b/flutter/example/pubspec_overrides.yaml index 90368dc0e6..35a595cd51 100644 --- a/flutter/example/pubspec_overrides.yaml +++ b/flutter/example/pubspec_overrides.yaml @@ -27,5 +27,4 @@ dependency_overrides: isar_generator: version: ^3.1.0 hosted: https://pub.isar-community.dev/ - sentry_supabase: - path: ../../supabase + From e4d1bf6d5e311cffb257340e5dd7bb57fe827886 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 16:00:41 +0200 Subject: [PATCH 31/32] format main --- flutter/example/lib/main.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 7e5feb607b..92e249563a 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -42,8 +42,6 @@ var _isIntegrationTest = false; final GlobalKey navigatorKey = GlobalKey(); Future main() async { - - await setupSentry( () => runApp( SentryWidget( From d28daa6d24ca89f061455e3a16897b637fd9e4dc Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 21 May 2025 16:02:10 +0200 Subject: [PATCH 32/32] add example to readme --- supabase/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/supabase/README.md b/supabase/README.md index 83db2c646d..908191b549 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -35,7 +35,24 @@ Integration for [`supabase`](https://pub.dev/packages/supabase) package. - 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