diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index bbd4239f84..7cad89147f 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 4f8c1ff013..7467a4d782 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: flutter: sdk: flutter + supabase_flutter: ^2.9.0 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/.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..eb796b2e8e --- /dev/null +++ b/supabase/lib/sentry_supabase.dart @@ -0,0 +1,3 @@ +library; + +export 'src/sentry_supabase_client.dart'; diff --git a/supabase/lib/src/operation.dart b/supabase/lib/src/operation.dart new file mode 100644 index 0000000000..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 new file mode 100644 index 0000000000..69fffcc6c8 --- /dev/null +++ b/supabase/lib/src/sentry_supabase_client.dart @@ -0,0 +1,183 @@ +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) { + 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 query = _readQuery(request); + final body = _readBody(table, request); + + 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 (body != null && _redactRequestBody != null) { + for (final entry in body.entries) { + body[entry.key] = _redactRequestBody(table, entry.key, entry.value); + } + } + 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": + 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/pubspec.yaml b/supabase/pubspec.yaml new file mode 100644 index 0000000000..c1bf951208 --- /dev/null +++ b/supabase/pubspec.yaml @@ -0,0 +1,18 @@ +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 + sentry: 9.0.0-alpha.2 + +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/sentry_supabase_client_test.dart b/supabase/test/sentry_supabase_client_test.dart new file mode 100644 index 0000000000..420912bf29 --- /dev/null +++ b/supabase/test/sentry_supabase_client_test.dart @@ -0,0 +1,292 @@ +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 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 sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + 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 sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + 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 sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + 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 sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + 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 sentrySupabaseClient = fixture.getSut(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + httpClient: sentrySupabaseClient, + ); + + 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 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); + }); + + 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': ''}); + }); + }); +} + +class Fixture { + final options = SentryOptions( + dsn: 'https://example.com/123', + ); + final mockClient = MockClient(); + final mockHub = MockHub(); + + SentrySupabaseClient getSut( + {bool breadcrumbs = true, + SentrySupabaseRedactRequestBody? redactRequestBody}) { + return SentrySupabaseClient( + breadcrumbs: breadcrumbs, + client: mockClient, + hub: mockHub, + redactRequestBody: redactRequestBody, + ); + } +} + +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}'; + } +}