From 6b79f49741d75d8b8a96e68a4e989e9efb315dda Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 21 Jan 2026 12:23:02 +0100 Subject: [PATCH 1/4] feat: implement comprehensive Sentry metrics strategy - Add SentryMetricsHelper with type-safe metric tracking functions - Implement QR code cache hit/miss/eviction metrics - Track QR code generation duration with accurate timing (CFAbsoluteTimeGetCurrent) - Add search tracking for lists and links views - Implement share extension completion and cancellation tracking - Add list pinning/unpinning metrics - Track link/list deletion (single and bulk) with context - Implement NFC availability and sharing metrics - Add link opened tracking with link ID attribute - Track memory warnings with cache size context - Remove redundant app lifecycle metrics (handled by Sentry Sessions) - Update all analytics calls to use metrics instead of events This implements the metrics strategy outlined in Documentation/Analytics.md, providing comprehensive insights while maintaining privacy compliance. --- Documentation/Analytics.md | 362 ++++++++++-- Targets/App/Sources/Main/FlinkyApp.swift | 11 +- .../Sources/Services/DataSeedingService.swift | 20 +- .../App/Sources/Services/QRCodeCache.swift | 14 + .../CreateLinkEditorContainerView.swift | 12 +- ...inkWithListPickerEditorContainerView.swift | 13 +- .../CreateLinkListEditorContainerView.swift | 11 +- .../LinkDetail/LinkDetailContainerView.swift | 47 +- .../UI/LinkDetail/LinkDetailRenderView.swift | 11 +- .../LinkDetailNFCSharingViewModel.swift | 39 +- .../UI/LinkInfo/LinkInfoContainerView.swift | 18 +- .../LinkListDetailContainerView.swift | 19 + .../LinkListInfoContainerView.swift | 20 +- .../UI/LinkLists/LinkListsContainerView.swift | 17 + .../Sources/Utils/SentryMetricsHelper.swift | 531 ++++++++++++++++++ .../Sources/ShareViewController.swift | 101 +++- 16 files changed, 1028 insertions(+), 218 deletions(-) create mode 100644 Targets/App/Sources/Utils/SentryMetricsHelper.swift diff --git a/Documentation/Analytics.md b/Documentation/Analytics.md index 290db95..5059c22 100644 --- a/Documentation/Analytics.md +++ b/Documentation/Analytics.md @@ -4,11 +4,12 @@ This document describes the analytics and error tracking implementation in the F ## Overview -Flinky implements a **privacy-first analytics strategy** that captures user behavior patterns and technical metrics while strictly protecting user-generated content (URLs, names, etc.). All tracking is implemented using Sentry with three primary mechanisms: +Flinky implements a **privacy-first analytics strategy** that captures user behavior patterns and technical metrics while strictly protecting user-generated content (URLs, names, etc.). All tracking is implemented using Sentry with four primary mechanisms: - **SwiftUI View Tracing**: Automatic performance monitoring of view rendering and navigation flows - **Breadcrumbs**: Session-scoped debugging context for error investigation -- **Analytics Events**: Persistent structured data for product insights +- **Metrics**: Aggregate counters for user behavior patterns (replaces individual analytics events) +- **Error Events**: Individual error events for debugging actual issues ## SwiftUI View Tracing @@ -116,6 +117,75 @@ struct LinkDetailContainerView: View { - All sharing methods: copy URL, QR codes, NFC, system share - Sharing success/failure patterns +## Metrics (Primary Analytics Mechanism) + +### Overview + +Sentry Metrics provide aggregate counters for tracking user behavior patterns. Unlike individual events, metrics don't create "issues" in Sentry and are better suited for analytics that only need aggregate counts. Metrics are enabled by default in Sentry SDK 9.2.0+. + +### When to Use Metrics vs Events + +**Use Metrics For:** + +- User actions (clicks, creations, sharing) +- Feature usage tracking +- Aggregate behavior patterns +- Analytics that only need counts + +**Use Events For:** + +- Actual errors that need investigation +- Exceptional conditions requiring debugging +- Issues that need individual attention + +### Metrics Helper Utility + +The app provides `SentryMetricsHelper` with type-safe wrapper functions for common metrics: + +```swift +import Sentry + +// Link creation +SentryMetricsHelper.trackLinkCreated(creationFlow: "direct", listId: list.id.uuidString) + +// List creation +SentryMetricsHelper.trackListCreated(creationFlow: "direct", autoCreated: false) + +// Link sharing +SentryMetricsHelper.trackLinkShared(sharingMethod: "copy_url", linkId: link.id.uuidString) + +// Customization +SentryMetricsHelper.trackColorSelected(color: color.rawValue, entityType: "link") +SentryMetricsHelper.trackSymbolSelected(symbol: symbol.rawValue, entityType: "list") + +// Feedback +SentryMetricsHelper.trackFeedbackFormOpened() +SentryMetricsHelper.trackFeedbackFormClosed() + +// Database seeding +SentryMetricsHelper.trackDatabaseSeedingStarted() +SentryMetricsHelper.trackDatabaseSeedingCompleted() +``` + +### Direct Metrics API Usage + +For cases not covered by the helper, use Sentry's metrics API directly: + +```swift +import Sentry + +// Counter metric +SentrySDK.metrics.count( + key: "custom.metric.name", + value: 1, + unit: .generic("unit"), + attributes: [ + "attribute_name": "value", + "entity_type": "link" + ] +) +``` + ## Implementation Patterns ### Breadcrumb Pattern @@ -132,18 +202,31 @@ breadcrumb.data = [ SentrySDK.addBreadcrumb(breadcrumb) ``` -### Analytics Event Pattern +### Metrics Pattern (Preferred for Analytics) ```swift -// Structured analytics event for product insights -let event = Event(level: .info) -event.message = SentryMessage(formatted: "event_name") -event.extra = [ - "entity_id": entity.id.uuidString, - "entity_type": "link|list", - "feature_context": "relevant_metadata" -] -SentrySDK.capture(event: event) +// Use metrics helper for type-safe analytics tracking +SentryMetricsHelper.trackLinkCreated(creationFlow: "direct", listId: list.id.uuidString) + +// Or use metrics API directly with attributes +SentrySDK.metrics.count( + key: "link.created", + value: 1, + unit: .generic("link"), + attributes: [ + "creation_flow": "direct", + "entity_type": "link", + "list_id": list.id.uuidString + ] +) +``` + +### Error Event Pattern (For Actual Errors) + +```swift +// Only use events for actual errors that need investigation +let appError = AppError.persistenceError(.saveLinkFailed(underlyingError: error.localizedDescription)) +SentrySDK.capture(error: appError) ``` ### Error Tracking Pattern @@ -160,29 +243,119 @@ do { } ``` -## Event Taxonomy +## Metrics Taxonomy + +### Currently Implemented Metrics + +The following metrics are currently tracked via `SentryMetricsHelper`: + +#### Creation Metrics + +- `link.created`: Counter for new links added to lists (attributes: `creation_flow`, `entity_type`, `list_id`) +- `list.created`: Counter for new lists created (attributes: `creation_flow`, `entity_type`, `auto_created`) + +#### Customization Metrics + +- `link.color.selected`: Counter for color customization on links (attributes: `color`, `entity_type`) +- `link.symbol.selected`: Counter for symbol customization on links (attributes: `symbol`, `entity_type`) +- `list.color.selected`: Counter for color customization on lists (attributes: `color`, `entity_type`) +- `list.symbol.selected`: Counter for symbol customization on lists (attributes: `symbol`, `entity_type`) + +#### Sharing Metrics + +- `link.shared`: Counter for all link sharing methods (attributes: `sharing_method`, `link_id`) +- `link.shared.nfc`: Counter for NFC-specific sharing (attributes: `sharing_method`, `link_id`) + +#### Feedback Metrics + +- `feedback.form.opened`: Counter for feedback form openings +- `feedback.form.closed`: Counter for feedback form closings + +#### Database Metrics + +- `database.seeding.started`: Counter for database seeding start +- `database.seeding.completed`: Counter for database seeding completion + +### Recommended Development Metrics + +The following metrics are recommended for future implementation to gain deeper insights into app performance, user behavior, and feature adoption. These are organized by priority and category. + +#### High Priority Metrics (Immediate Value) -### Creation Events +**Performance Metrics** -- `link_created`: New link added to a list -- `list_created`: New list created +- `qr_code.generation.duration` (Distribution) - Track QR code generation time + - Attributes: `cache_hit` (boolean), `image_size` (string) + - Implementation: Measure in `LinkDetailContainerView.createQRCodeImageInBackground()` -### Update Events +- `qr_code.cache.hit` (Counter) - Track cache hits + - Implementation: Track in `QRCodeCache.image(forContent:)` when image found -- `link_color_selected`: Color customization for links -- `link_symbol_selected`: Symbol customization for links -- `list_color_selected`: Color customization for lists -- `list_symbol_selected`: Symbol customization for lists +- `qr_code.cache.miss` (Counter) - Track cache misses + - Implementation: Track in `QRCodeCache.image(forContent:)` when image not found -### Sharing Events +**Error Rate Metrics** -- `link_shared`: Universal sharing event with method specification -- `link_shared_nfc`: NFC-specific sharing event for detailed analysis +- `error.rate` (Counter) - Track error frequency by type + - Attributes: `error_type` ("persistence" | "qr_generation" | "nfc" | "validation" | "data_corruption") + - Implementation: Aggregate from existing `SentrySDK.capture(error:)` calls -### Interaction Events +**Feature Adoption Metrics** -- Link opening events (tracked via breadcrumbs) -- Navigation patterns (tracked via breadcrumbs) +- `share_extension.completed` (Counter) - Track successful share extension completions + - Attributes: `list_selected` (boolean), `name_edited` (boolean) + - Implementation: Track in `ShareViewController.didSelectPost()` when save succeeds + +- `search.performed` (Counter) - Track search usage + - Attributes: `search_context` ("lists" | "links"), `result_count` (number) + - Implementation: Track when `searchText` changes from empty to non-empty + +#### Medium Priority Metrics (Useful Insights) + +**User Behavior Metrics** + +- `list.pinned` / `list.unpinned` (Counter) - Track list pinning actions +- `link.deleted` / `list.deleted` (Counter) - Track deletion patterns + - Attributes: `link_count` (for lists), `list_link_count` (for links) +- `link.opened` (Counter) - Track Safari link opens + - Attributes: `sharing_method` (optional) + +**Feature Adoption Metrics** + +- `nfc.share.initiated` / `nfc.share.success` / `nfc.share.failed` (Counter) - Track NFC usage +- `nfc.available` (Gauge) - Track devices with NFC capability + +**Performance Metrics** + +- `database.query.duration` (Distribution) - Track SwiftData query performance + - Attributes: `query_type` ("fetch_lists" | "fetch_links" | "save" | "delete") +- `database.save.duration` (Distribution) - Track save operation performance + - Attributes: `entity_type`, `operation` ("create" | "update" | "delete") + +#### Low Priority Metrics (Nice to Have) + +**User Flow Metrics** + +- `flow.creation.abandoned` (Counter) - Track abandoned creation flows + - Attributes: `flow_type` ("link" | "list"), `step` ("name" | "url" | "customization") +- `flow.creation.completed` (Counter) - Track completed creation flows + - Attributes: `flow_type`, `duration_seconds` + +**App Health Metrics** + +- `app.launch` (Counter) - Track app launches + - Attributes: `is_cold_start` (boolean) +- `app.session.duration` (Distribution) - Track session duration +- `memory.warning.received` (Counter) - Track memory warnings + - Attributes: `cache_size_at_warning` (number) + +**Data Health Metrics** + +- `data.links.per_list` (Distribution) - Average links per list (periodic calculation) +- `data.lists.count` (Distribution) - Number of lists per user (periodic calculation) +- `data.total.links` (Distribution) - Total links per user (periodic calculation) + +See the [Implementation Notes](#implementation-notes) section for detailed implementation guidance for each metric. ## Data Structure Standards @@ -239,16 +412,8 @@ breadcrumb.data = [ ] SentrySDK.addBreadcrumb(breadcrumb) -// Analytics event for product insights -let event = Event(level: .info) -event.message = SentryMessage(formatted: "link_created") -event.extra = [ - "link_id": link.id.uuidString, - "list_id": list.id.uuidString, - "entity_type": "link", - "creation_flow": "direct" -] -SentrySDK.capture(event: event) +// Track creation using metrics (preferred for analytics) +SentryMetricsHelper.trackLinkCreated(creationFlow: "direct", listId: list.id.uuidString) ``` ### Sharing Tracking @@ -263,14 +428,8 @@ breadcrumb.data = [ ] SentrySDK.addBreadcrumb(breadcrumb) -// Universal sharing analytics -let event = Event(level: .info) -event.message = SentryMessage(formatted: "link_shared") -event.extra = [ - "link_id": item.id.uuidString, - "sharing_method": "qr_code_share" -] -SentrySDK.capture(event: event) +// Track sharing using metrics (preferred for analytics) +SentryMetricsHelper.trackLinkShared(sharingMethod: "qr_code_share", linkId: item.id.uuidString) ``` ### Customization Tracking @@ -278,16 +437,19 @@ SentrySDK.capture(event: event) ```swift // Only track when user actually makes changes if colorChanged { - let colorEvent = Event(level: .info) - colorEvent.message = SentryMessage(formatted: "link_color_selected") - colorEvent.extra = [ - "color": color.rawValue, - "entity_type": "link" - ] - SentrySDK.capture(event: colorEvent) + // Track using metrics (preferred for analytics) + SentryMetricsHelper.trackColorSelected(color: color.rawValue, entityType: "link") } ``` +### NFC Sharing (Dual Metrics) + +```swift +// NFC sharing generates dual metrics for comprehensive analysis +SentryMetricsHelper.trackLinkSharedNFC(linkId: link.id.uuidString) // NFC-specific +SentryMetricsHelper.trackLinkShared(sharingMethod: "nfc", linkId: link.id.uuidString) // General sharing +``` + ## Special Considerations ### NFC Sharing @@ -344,8 +506,8 @@ event.context = [ ### Data Retention - **Breadcrumbs**: Session-scoped, cleared on app restart -- **Events**: Persistent analytics data (no PII) -- **Errors**: Sanitized technical information only +- **Metrics**: Aggregated counters for analytics (no PII) +- **Error Events**: Sanitized technical information only ### Debugging Capability @@ -353,14 +515,88 @@ event.context = [ - Error reproduction without privacy concerns - Performance analysis with user behavior context +## Metrics Migration + +### Migration from Events to Metrics + +As of the metrics migration, analytics tracking has moved from individual `SentrySDK.capture(event:)` calls to Sentry Metrics API. This provides: + +- **Better Aggregation**: Built-in aggregation and querying capabilities +- **Reduced Noise**: Metrics don't create individual "issues" in Sentry +- **Performance**: More efficient than individual events +- **Cost**: Potentially lower cost than individual events + +### Migration Checklist + +All analytics events have been migrated to metrics: + +- ✅ Link creation events → `link.created` metric +- ✅ List creation events → `list.created` metric +- ✅ Sharing events → `link.shared` metric +- ✅ Customization events → `link.color.selected`, `link.symbol.selected`, etc. +- ✅ Feedback events → `feedback.form.opened`, `feedback.form.closed` metrics +- ✅ Database seeding events → `database.seeding.started`, `database.seeding.completed` metrics + +Error events (`SentrySDK.capture(error:)`) remain as events since they represent actual issues that need investigation. + +## Implementation Notes + +### Metric Naming Convention + +Follow the established pattern: `category.subcategory.metric` + +- Use dot-delimited lowercase +- Be descriptive but concise +- Group related metrics by category +- Examples: + - `qr_code.generation.duration` + - `link.deleted.bulk` + - `share_extension.completed` + +### Attributes Best Practices + +- Keep attributes minimal (max 5-7 per metric) +- Use consistent attribute names across metrics +- Prefer enums/strings over free-form text +- Include context that enables filtering/grouping +- Examples: + - `entity_type`: "link" | "list" + - `error_type`: "persistence" | "qr_generation" | "nfc" + - `search_context`: "lists" | "links" + +### Real-time vs Periodic Metrics + +- **Real-time**: User actions, errors, performance measurements +- **Periodic**: Data distribution metrics (calculate on app launch or background) + +### Leveraging Existing Infrastructure + +- **Sentry Tracing**: Use for view rendering performance (already implemented) +- **Error Events**: Aggregate existing error captures for error rate metrics +- **QRCodeCache**: Add metrics tracking directly in cache class +- **SwiftData**: Wrap operations for database performance metrics + +### Questions These Metrics Answer + +1. **Performance**: Are QR codes generating fast enough? Is cache effective? +2. **Adoption**: Are users discovering and using key features (NFC, search, customization)? +3. **Reliability**: What's the error rate? Are errors recoverable? +4. **Usage Patterns**: How do users organize their links? How many links/lists do they have? +5. **Feature Value**: Which features provide the most value? Which are rarely used? +6. **User Experience**: Where do users abandon flows? How deep do they navigate? +7. **Data Health**: Are users creating empty lists? How often do they update links? + ## Future Considerations -### Adding New Events +### Adding New Metrics -1. Follow established naming convention: `entity_action_context` -2. Include standard fields: `entity_id`, `entity_type` -3. Exclude any PII or user-generated content -4. Document in this file with examples +1. Use `SentryMetricsHelper` for common patterns when possible +2. Follow established naming convention: `entity.action.detail` (dot-delimited lowercase) +3. Include standard attributes: `entity_type`, `creation_flow`, etc. +4. Exclude any PII or user-generated content +5. Document in this file with examples +6. Add helper function to `SentryMetricsHelper` if pattern is reusable +7. Prioritize high-value metrics that answer specific development questions ### Privacy Reviews @@ -379,9 +615,11 @@ event.context = [ When adding new analytics: - [ ] Uses privacy-safe data only (no URLs, names, content) -- [ ] Follows established event naming conventions -- [ ] Includes standard entity identification fields -- [ ] Has both breadcrumb (debugging) and event (analytics) tracking +- [ ] Uses metrics (not events) for analytics tracking +- [ ] Follows established metric naming conventions (`entity.action.detail`) +- [ ] Includes standard entity identification attributes +- [ ] Has both breadcrumb (debugging) and metric (analytics) tracking +- [ ] Uses `SentryMetricsHelper` when possible, or metrics API directly - [ ] Includes explanatory comments explaining the tracking rationale - [ ] Tested to ensure compilation and runtime success - [ ] Documented in this file with examples diff --git a/Targets/App/Sources/Main/FlinkyApp.swift b/Targets/App/Sources/Main/FlinkyApp.swift index dfefb33..da64ec9 100644 --- a/Targets/App/Sources/Main/FlinkyApp.swift +++ b/Targets/App/Sources/Main/FlinkyApp.swift @@ -224,23 +224,22 @@ struct FlinkyApp: App { breadcrumb.message = "User opened feedback form" SentrySDK.addBreadcrumb(breadcrumb) - let event = Event(level: .info) - event.message = SentryMessage(formatted: "User opened feedback form") - SentrySDK.capture(event: event) + // Track feedback form opening using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackFeedbackFormOpened() } feedbackOptions.onFormClose = { let breadcrumb = Breadcrumb(level: .info, category: "user_feedback") breadcrumb.message = "User closed feedback form" SentrySDK.addBreadcrumb(breadcrumb) - let event = Event(level: .info) - event.message = SentryMessage(formatted: "User closed feedback form") - SentrySDK.capture(event: event) + // Track feedback form closing using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackFeedbackFormClosed() } } // Configure Other Options options.experimental.enableUnhandledCPPExceptionsV2 = false + options.experimental.enableMetrics = true // Configure Logs options.enableLogs = true diff --git a/Targets/App/Sources/Services/DataSeedingService.swift b/Targets/App/Sources/Services/DataSeedingService.swift index 26b2b68..65be6d7 100644 --- a/Targets/App/Sources/Services/DataSeedingService.swift +++ b/Targets/App/Sources/Services/DataSeedingService.swift @@ -41,26 +41,14 @@ final class DataSeedingService { seedingBreadcrumb.timestamp = Date() SentrySDK.addBreadcrumb(seedingBreadcrumb) - // Capture seeding event for analytics - SentrySDK.capture(message: "Database seeding started") { scope in - scope.setLevel(.info) - scope.setTag(value: "database_seeding", key: "operation") - scope.setContext( - value: [ - "action": "initial_seed", - "timestamp": ISO8601DateFormatter().string(from: Date()) - ], key: "seeding") - } + // Track seeding start using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackDatabaseSeedingStarted() seedInitialData(modelContext: modelContext) markDatabaseAsSeeded(modelContext: modelContext) - // Capture successful seeding completion - SentrySDK.capture(message: "Database seeding completed successfully") { scope in - scope.setLevel(.info) - scope.setTag(value: "database_seeding", key: "operation") - scope.setTag(value: "success", key: "status") - } + // Track seeding completion using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackDatabaseSeedingCompleted() } // MARK: - Private Implementation diff --git a/Targets/App/Sources/Services/QRCodeCache.swift b/Targets/App/Sources/Services/QRCodeCache.swift index 5db2417..c07e260 100644 --- a/Targets/App/Sources/Services/QRCodeCache.swift +++ b/Targets/App/Sources/Services/QRCodeCache.swift @@ -1,5 +1,6 @@ import FlinkyCore import Foundation +import Sentry import UIKit import os.log @@ -40,6 +41,8 @@ class QRCodeCache: NSObject { @objc private func handleMemoryWarning() { Self.logger.warning("Received memory warning, clearing QR code cache") + let cacheSize = storage.countLimit + SentryMetricsHelper.trackMemoryWarningReceived(cacheSizeAtWarning: cacheSize) clearCache() } @@ -49,8 +52,10 @@ class QRCodeCache: NSObject { if image != nil { Self.logger.debug("QR code cache hit for content") + SentryMetricsHelper.trackQRCodeCacheHit() } else { Self.logger.debug("QR code cache miss for content") + SentryMetricsHelper.trackQRCodeCacheMiss() } return image @@ -78,9 +83,17 @@ class QRCodeCache: NSObject { } var cacheInfo: (count: Int, totalCost: Int) { + // Note: NSCache doesn't expose current count/cost, only limits return (storage.countLimit, storage.totalCostLimit) } + /// Returns current cache size metrics for tracking + var currentCacheSize: Int { + // NSCache doesn't expose current count, so we return the limit + // Actual tracking happens via hit/miss/eviction metrics + return storage.countLimit + } + deinit { NotificationCenter.default.removeObserver(self) } @@ -91,5 +104,6 @@ class QRCodeCache: NSObject { extension QRCodeCache: NSCacheDelegate { func cache(_: NSCache, willEvictObject _: Any) { Self.logger.debug("QR code cache evicting object due to memory pressure") + SentryMetricsHelper.trackQRCodeCacheEviction(reason: "size_limit") } } diff --git a/Targets/App/Sources/UI/CreateLinkEditor/CreateLinkEditorContainerView.swift b/Targets/App/Sources/UI/CreateLinkEditor/CreateLinkEditorContainerView.swift index c97684f..f20626d 100644 --- a/Targets/App/Sources/UI/CreateLinkEditor/CreateLinkEditorContainerView.swift +++ b/Targets/App/Sources/UI/CreateLinkEditor/CreateLinkEditorContainerView.swift @@ -47,16 +47,8 @@ struct CreateLinkEditorContainerView: View { ] SentrySDK.addBreadcrumb(breadcrumb) - // Track usage event for analytics - using Event object instead of simple message - // to capture structured metadata for better analytics querying and filtering - let event = Event(level: .info) - event.message = SentryMessage(formatted: "link_created") - event.extra = [ - "link_id": link.id.uuidString, - "list_id": list.id.uuidString, - "entity_type": "link" // Consistent entity typing for cross-feature analytics - ] - SentrySDK.capture(event: event) + // Track link creation using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackLinkCreated(creationFlow: "direct", listId: list.id.uuidString) dismiss() } catch { diff --git a/Targets/App/Sources/UI/CreateLinkWithoutListEditor/CreateLinkWithListPickerEditorContainerView.swift b/Targets/App/Sources/UI/CreateLinkWithoutListEditor/CreateLinkWithListPickerEditorContainerView.swift index 15a9bd8..85b881c 100644 --- a/Targets/App/Sources/UI/CreateLinkWithoutListEditor/CreateLinkWithListPickerEditorContainerView.swift +++ b/Targets/App/Sources/UI/CreateLinkWithoutListEditor/CreateLinkWithListPickerEditorContainerView.swift @@ -72,17 +72,8 @@ struct CreateLinkWithListPickerEditorContainerView: View { ] SentrySDK.addBreadcrumb(breadcrumb) - // Track usage event with structured data for analytics segmentation - // This helps distinguish between different link creation flows - let event = Event(level: .info) - event.message = SentryMessage(formatted: "link_created") - event.extra = [ - "link_id": newItem.id.uuidString, - "list_id": list.id.uuidString, - "entity_type": "link", - "creation_flow": "list_picker" // Distinguish from direct list creation - ] - SentrySDK.capture(event: event) + // Track link creation using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackLinkCreated(creationFlow: "list_picker", listId: list.id.uuidString) dismiss() } catch { diff --git a/Targets/App/Sources/UI/CreateListEditor/CreateLinkListEditorContainerView.swift b/Targets/App/Sources/UI/CreateListEditor/CreateLinkListEditorContainerView.swift index 5777303..f1a2dc2 100644 --- a/Targets/App/Sources/UI/CreateListEditor/CreateLinkListEditorContainerView.swift +++ b/Targets/App/Sources/UI/CreateListEditor/CreateLinkListEditorContainerView.swift @@ -35,15 +35,8 @@ struct CreateLinkListEditorContainerView: View { ] SentrySDK.addBreadcrumb(breadcrumb) - // Track usage event for analytics - all PII excluded for privacy compliance - // Use Event object for consistent structured data across all creation events - let event = Event(level: .info) - event.message = SentryMessage(formatted: "list_created") - event.extra = [ - "list_id": newItem.id.uuidString, - "entity_type": "list" // Enables cross-entity analytics queries - ] - SentrySDK.capture(event: event) + // Track list creation using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackListCreated(creationFlow: "direct") dismiss() } catch { diff --git a/Targets/App/Sources/UI/LinkDetail/LinkDetailContainerView.swift b/Targets/App/Sources/UI/LinkDetail/LinkDetailContainerView.swift index 904c62e..3ba7dba 100644 --- a/Targets/App/Sources/UI/LinkDetail/LinkDetailContainerView.swift +++ b/Targets/App/Sources/UI/LinkDetail/LinkDetailContainerView.swift @@ -62,6 +62,9 @@ struct LinkDetailContainerView: View { "link_id": item.id.uuidString // Enables correlation with link usage patterns ] SentrySDK.addBreadcrumb(breadcrumb) + + // Track link opened using metrics + SentryMetricsHelper.trackLinkOpened(linkId: item.id.uuidString) } } }, @@ -81,15 +84,8 @@ struct LinkDetailContainerView: View { ] SentrySDK.addBreadcrumb(breadcrumb) - // Track sharing event for analytics - standardized sharing_method field - // enables cross-method comparison of sharing popularity - let event = Event(level: .info) - event.message = SentryMessage(formatted: "link_shared") - event.extra = [ - "link_id": item.id.uuidString, - "sharing_method": "copy_url" // Consistent taxonomy across all sharing methods - ] - SentrySDK.capture(event: event) + // Track sharing using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackLinkShared(sharingMethod: "copy_url", linkId: item.id.uuidString) }, shareQRCodeImageAction: { image in imageToShare = .init(image: image) @@ -104,15 +100,8 @@ struct LinkDetailContainerView: View { ] SentrySDK.addBreadcrumb(breadcrumb) - // Track sharing event with consistent method taxonomy - // Enables comparison between QR sharing vs other sharing methods - let event = Event(level: .info) - event.message = SentryMessage(formatted: "link_shared") - event.extra = [ - "link_id": item.id.uuidString, - "sharing_method": "qr_code_share" // Part of standardized sharing method vocabulary - ] - SentrySDK.capture(event: event) + // Track sharing using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackLinkShared(sharingMethod: "qr_code_share", linkId: item.id.uuidString) }, saveQRCodeImageToPhotos: { image in saveImageToPhotos(image) @@ -142,7 +131,12 @@ struct LinkDetailContainerView: View { } func createQRCodeImageInBackground() async { + let startTime = CFAbsoluteTimeGetCurrent() + if let cachedImage = qrcodeCache.image(forContent: item.url.absoluteString) { + let duration = CFAbsoluteTimeGetCurrent() - startTime + let imageSize = "\(Int(cachedImage.size.width))x\(Int(cachedImage.size.height))" + SentryMetricsHelper.trackQRCodeGenerationDuration(duration: duration, cacheHit: true, imageSize: imageSize) image = .success(cachedImage) return } @@ -169,10 +163,14 @@ struct LinkDetailContainerView: View { let uiImage = UIImage(cgImage: cgImage) qrcodeCache.setImage(uiImage, forContent: item.url.absoluteString) + let duration = CFAbsoluteTimeGetCurrent() - startTime + let imageSize = "\(Int(uiImage.size.width))x\(Int(uiImage.size.height))" + SentryMetricsHelper.trackQRCodeGenerationDuration(duration: duration, cacheHit: false, imageSize: imageSize) + image = .success(uiImage) } - func saveImageToPhotos(_ image: UIImage) { // swiftlint:disable:this function_body_length + func saveImageToPhotos(_ image: UIImage) { PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in switch status { case .authorized, .limited: @@ -193,15 +191,8 @@ struct LinkDetailContainerView: View { ] SentrySDK.addBreadcrumb(breadcrumb) - // Track as sharing event even though it's save-for-later - // Indicates sharing intent and helps measure QR code popularity - let event = Event(level: .info) - event.message = SentryMessage(formatted: "link_shared") - event.extra = [ - "link_id": item.id.uuidString, - "sharing_method": "qr_code_save" // Distinct from qr_code_share - ] - SentrySDK.capture(event: event) + // Track sharing using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackLinkShared(sharingMethod: "qr_code_save", linkId: item.id.uuidString) } else { let localDescription = "Failed to save QR code to Photos: \(error?.localizedDescription ?? "Unknown error")" diff --git a/Targets/App/Sources/UI/LinkDetail/LinkDetailRenderView.swift b/Targets/App/Sources/UI/LinkDetail/LinkDetailRenderView.swift index 0bcb80f..cb03aa0 100644 --- a/Targets/App/Sources/UI/LinkDetail/LinkDetailRenderView.swift +++ b/Targets/App/Sources/UI/LinkDetail/LinkDetailRenderView.swift @@ -151,15 +151,8 @@ struct LinkDetailRenderView: View { ] SentrySDK.addBreadcrumb(breadcrumb) - // Track sharing event for analytics - using detailed Event object - // to capture structured data for better analytics queries - let event = Event(level: .info) - event.message = SentryMessage(formatted: "link_shared") - event.extra = [ - "link_id": linkId.uuidString, - "sharing_method": "system_share" - ] - SentrySDK.capture(event: event) + // Track sharing using metrics - better for aggregate counts than individual events + SentryMetricsHelper.trackLinkShared(sharingMethod: "system_share", linkId: linkId.uuidString) } ) } diff --git a/Targets/App/Sources/UI/LinkDetailNFCSharing/LinkDetailNFCSharingViewModel.swift b/Targets/App/Sources/UI/LinkDetailNFCSharing/LinkDetailNFCSharingViewModel.swift index 78f9fd6..e4ad916 100644 --- a/Targets/App/Sources/UI/LinkDetailNFCSharing/LinkDetailNFCSharingViewModel.swift +++ b/Targets/App/Sources/UI/LinkDetailNFCSharing/LinkDetailNFCSharingViewModel.swift @@ -23,9 +23,25 @@ final class LinkDetailNFCSharingViewModel: ObservableObject { let appError = AppError.nfcError(localDescription) self.errorHandler?(appError) state = .error("NFC not available on this device") + // Track NFC availability (not available) + SentrySDK.metrics.gauge( + key: "nfc.available", + value: 0.0, + unit: .generic("capability") + ) return } + // Track NFC availability (available) + SentrySDK.metrics.gauge( + key: "nfc.available", + value: 1.0, + unit: .generic("capability") + ) + + // Track NFC share initiated + SentryMetricsHelper.trackNFCShareInitiated() + state = .scanning let delegate = createDelegate() @@ -43,7 +59,7 @@ final class LinkDetailNFCSharingViewModel: ObservableObject { DispatchQueue.main.async { self.state = .success - // Analytics breadcrumbs and events for NFC success + // Analytics breadcrumbs and metrics for NFC success let breadcrumb = Breadcrumb(level: .info, category: "link_sharing") breadcrumb.message = "Link shared via NFC successfully" breadcrumb.data = [ @@ -52,21 +68,10 @@ final class LinkDetailNFCSharingViewModel: ObservableObject { ] SentrySDK.addBreadcrumb(breadcrumb) - let nfcEvent = Event(level: .info) - nfcEvent.message = SentryMessage(formatted: "link_shared_nfc") - nfcEvent.extra = [ - "link_id": self.link.id.uuidString, - "sharing_method": "nfc" - ] - SentrySDK.capture(event: nfcEvent) - - let shareEvent = Event(level: .info) - shareEvent.message = SentryMessage(formatted: "link_shared") - shareEvent.extra = [ - "link_id": self.link.id.uuidString, - "sharing_method": "nfc" - ] - SentrySDK.capture(event: shareEvent) + // Track NFC-specific sharing and general sharing using metrics + SentryMetricsHelper.trackLinkSharedNFC(linkId: self.link.id.uuidString) + SentryMetricsHelper.trackLinkShared(sharingMethod: "nfc", linkId: self.link.id.uuidString) + SentryMetricsHelper.trackNFCShareSuccess() } }, onError: { [weak self] errorMessage in @@ -76,6 +81,8 @@ final class LinkDetailNFCSharingViewModel: ObservableObject { let appError = AppError.nfcError(localDescription) self.errorHandler?(appError) self.state = .error(errorMessage) + // Track NFC share failure + SentryMetricsHelper.trackNFCShareFailed(errorType: "tag_error") } } ) diff --git a/Targets/App/Sources/UI/LinkInfo/LinkInfoContainerView.swift b/Targets/App/Sources/UI/LinkInfo/LinkInfoContainerView.swift index d86702e..ac21f0d 100644 --- a/Targets/App/Sources/UI/LinkInfo/LinkInfoContainerView.swift +++ b/Targets/App/Sources/UI/LinkInfo/LinkInfoContainerView.swift @@ -58,26 +58,14 @@ struct LinkInfoContainerView: View { SentrySDK.addBreadcrumb(breadcrumb) // Track color selection only when changed to avoid noise - // Separate events for color/symbol enable customization feature analysis + // Using metrics for better aggregate counts than individual events if isColorChanged { - let colorEvent = Event(level: .info) - colorEvent.message = SentryMessage(formatted: "link_color_selected") - colorEvent.extra = [ - "color": color.rawValue, // Track which colors are popular - "entity_type": "link" // Enables comparison with list color usage - ] - SentrySDK.capture(event: colorEvent) + SentryMetricsHelper.trackColorSelected(color: color.rawValue, entityType: "link") } // Track symbol selection separately for granular customization analytics if isSymbolChanged { - let symbolEvent = Event(level: .info) - symbolEvent.message = SentryMessage(formatted: "link_symbol_selected") - symbolEvent.extra = [ - "symbol": symbol.rawValue, // Track symbol popularity - "entity_type": "link" // Cross-entity symbol usage comparison - ] - SentrySDK.capture(event: symbolEvent) + SentryMetricsHelper.trackSymbolSelected(symbol: symbol.rawValue, entityType: "link") } dismiss() diff --git a/Targets/App/Sources/UI/LinkListDetail/LinkListDetailContainerView.swift b/Targets/App/Sources/UI/LinkListDetail/LinkListDetailContainerView.swift index 45808e2..b0b327b 100644 --- a/Targets/App/Sources/UI/LinkListDetail/LinkListDetailContainerView.swift +++ b/Targets/App/Sources/UI/LinkListDetail/LinkListDetailContainerView.swift @@ -18,6 +18,7 @@ struct LinkListDetailContainerView: View { @State private var presentedLink: LinkModel? @State private var editingLink: LinkModel? @State private var searchText = "" + @State private var previousSearchText = "" @State private var linkToDelete: LinkModel? @State private var isDeleteLinkPresented = false @@ -36,6 +37,9 @@ struct LinkListDetailContainerView: View { modelContext.delete(link) do { try modelContext.save() + // Track link deletion + let remainingLinkCount = list.links.count - 1 + SentryMetricsHelper.trackLinkDeleted(listLinkCount: remainingLinkCount) } catch { Self.logger.error("Failed to delete link: \(error)") let appError = AppError.persistenceError( @@ -54,11 +58,14 @@ struct LinkListDetailContainerView: View { presenting: linksToDelete ) { links in Button(role: .destructive) { + let count = links.count for model in links { modelContext.delete(model) } do { try modelContext.save() + // Track bulk link deletion + SentryMetricsHelper.trackLinkDeletedBulk(count: count, listId: list.id.uuidString) } catch { Self.logger.error("Failed to delete multiple links: \(error)") let appError = AppError.persistenceError( @@ -74,10 +81,13 @@ struct LinkListDetailContainerView: View { } .alert(L10n.Shared.DeleteConfirmation.List.alertTitle(list.name), isPresented: $isDeleteListPresented) { Button(role: .destructive) { + let linkCount = list.links.count modelContext.delete(list) do { try modelContext.save() + // Track list deletion + SentryMetricsHelper.trackListDeleted(linkCount: linkCount) dismiss() } catch { Self.logger.error("Failed to delete list: \(error)") @@ -159,6 +169,15 @@ struct LinkListDetailContainerView: View { } ) .sentryTrace("LINK_LIST_DETAIL_VIEW") + .onChange(of: searchText) { oldValue, newValue in + // Track search when user starts searching (transitions from empty to non-empty) + if oldValue.isEmpty && !newValue.isEmpty { + let resultCount = filteredLinks.count + SentryMetricsHelper.trackSearchPerformed(searchContext: "links", resultCount: resultCount) + SentryMetricsHelper.trackSearchQueryLength(length: newValue.count, searchContext: "links") + } + previousSearchText = oldValue + } } // MARK: - Data diff --git a/Targets/App/Sources/UI/LinkListInfo/LinkListInfoContainerView.swift b/Targets/App/Sources/UI/LinkListInfo/LinkListInfoContainerView.swift index 73ea334..4badad3 100644 --- a/Targets/App/Sources/UI/LinkListInfo/LinkListInfoContainerView.swift +++ b/Targets/App/Sources/UI/LinkListInfo/LinkListInfoContainerView.swift @@ -52,26 +52,14 @@ struct LinkListInfoContainerView: View { SentrySDK.addBreadcrumb(breadcrumb) // Track list color selection for customization feature analytics - // Only fire when actually changed to reduce event volume + // Using metrics for better aggregate counts than individual events if isColorChanged { - let colorEvent = Event(level: .info) - colorEvent.message = SentryMessage(formatted: "list_color_selected") - colorEvent.extra = [ - "color": color.rawValue, // Track which list colors are popular - "entity_type": "list" // Enables cross-entity color preference analysis - ] - SentrySDK.capture(event: colorEvent) + SentryMetricsHelper.trackColorSelected(color: color.rawValue, entityType: "list") } - // Track list symbol selection with consistent event structure + // Track list symbol selection with consistent structure if isSymbolChanged { - let symbolEvent = Event(level: .info) - symbolEvent.message = SentryMessage(formatted: "list_symbol_selected") - symbolEvent.extra = [ - "symbol": symbol.rawValue, // Measure symbol popularity for lists - "entity_type": "list" // Compare with link symbol usage patterns - ] - SentrySDK.capture(event: symbolEvent) + SentryMetricsHelper.trackSymbolSelected(symbol: symbol.rawValue, entityType: "list") } dismiss() diff --git a/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift b/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift index 75e7adc..3de36a3 100644 --- a/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift +++ b/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift @@ -28,6 +28,7 @@ struct LinkListsContainerView: View { @State private var presentedInfoList: LinkListModel? @State private var searchText = "" + @State private var previousSearchText = "" @State private var listToDelete: LinkListModel? @State private var isDeleteListPresented = false @@ -45,10 +46,13 @@ struct LinkListsContainerView: View { isPresented: $isDeleteListPresented, presenting: listToDelete ) { list in Button(role: .destructive) { + let linkCount = list.links.count modelContext.delete(list) do { try modelContext.save() + // Track list deletion + SentryMetricsHelper.trackListDeleted(linkCount: linkCount) } catch { Self.logger.error("Failed to delete list: \(error)") let appError = AppError.persistenceError( @@ -131,6 +135,8 @@ struct LinkListsContainerView: View { do { try modelContext.save() + // Track list pinning + SentryMetricsHelper.trackListPinned() } catch { Self.logger.error("Failed to pin list: \(error)") let appError = AppError.persistenceError( @@ -152,6 +158,8 @@ struct LinkListsContainerView: View { do { try modelContext.save() + // Track list unpinning + SentryMetricsHelper.trackListUnpinned() } catch { Self.logger.error("Failed to unpin list: \(error)") let appError = AppError.persistenceError( @@ -193,6 +201,15 @@ struct LinkListsContainerView: View { // Therefore we manually trigger it when the view appears. SentrySDK.feedback.showWidget() } + .onChange(of: searchText) { oldValue, newValue in + // Track search when user starts searching (transitions from empty to non-empty) + if oldValue.isEmpty && !newValue.isEmpty { + let resultCount = filteredPinnedLists.count + filteredUnpinnedLists.count + SentryMetricsHelper.trackSearchPerformed(searchContext: "lists", resultCount: resultCount) + SentryMetricsHelper.trackSearchQueryLength(length: newValue.count, searchContext: "lists") + } + previousSearchText = oldValue + } } var pinnedListDisplayItems: [LinkListsDisplayItem] { diff --git a/Targets/App/Sources/Utils/SentryMetricsHelper.swift b/Targets/App/Sources/Utils/SentryMetricsHelper.swift new file mode 100644 index 0000000..277cb50 --- /dev/null +++ b/Targets/App/Sources/Utils/SentryMetricsHelper.swift @@ -0,0 +1,531 @@ +// swiftlint:disable type_body_length file_length +import Foundation +import Sentry + +/// Helper utility for tracking analytics metrics using Sentry Metrics API. +/// +/// This utility provides type-safe wrapper functions for common metric patterns, +/// replacing the previous `SentrySDK.capture(event:)` calls for analytics tracking. +/// Metrics are better suited for tracking aggregate counts and don't create individual "issues" in Sentry. +enum SentryMetricsHelper { + // MARK: - Link Creation + + /// Tracks link creation with creation flow and entity type attributes. + /// - Parameters: + /// - creationFlow: The flow used to create the link (e.g., "direct", "list_picker", "share_extension") + /// - listId: The ID of the list the link was added to + static func trackLinkCreated(creationFlow: String, listId: String) { + SentrySDK.metrics.count( + key: "link.created", + value: 1, + unit: .generic("link"), + attributes: [ + "creation_flow": creationFlow, + "entity_type": "link", + "list_id": listId + ] + ) + } + + // MARK: - List Creation + + /// Tracks list creation with creation flow and auto-created attributes. + /// - Parameters: + /// - creationFlow: The flow used to create the list (e.g., "direct", "share_extension") + /// - autoCreated: Whether the list was auto-created (e.g., default list in share extension) + static func trackListCreated(creationFlow: String, autoCreated: Bool = false) { + SentrySDK.metrics.count( + key: "list.created", + value: 1, + unit: .generic("list"), + attributes: [ + "creation_flow": creationFlow, + "entity_type": "list", + "auto_created": autoCreated + ] + ) + } + + // MARK: - Link Sharing + + /// Tracks link sharing with sharing method attribute. + /// - Parameters: + /// - sharingMethod: The method used to share (e.g., "copy_url", "qr_code_share", "qr_code_save", "nfc", "system_share") + /// - linkId: The ID of the link being shared + static func trackLinkShared(sharingMethod: String, linkId: String) { + SentrySDK.metrics.count( + key: "link.shared", + value: 1, + unit: .generic("share"), + attributes: [ + "sharing_method": sharingMethod, + "link_id": linkId + ] + ) + } + + /// Tracks NFC-specific link sharing for detailed analysis. + /// - Parameter linkId: The ID of the link being shared via NFC + static func trackLinkSharedNFC(linkId: String) { + SentrySDK.metrics.count( + key: "link.shared.nfc", + value: 1, + unit: .generic("share"), + attributes: [ + "sharing_method": "nfc", + "link_id": linkId + ] + ) + } + + // MARK: - Customization + + /// Tracks color selection for links or lists. + /// - Parameters: + /// - color: The selected color raw value + /// - entityType: The type of entity ("link" or "list") + static func trackColorSelected(color: String, entityType: String) { + let metricKey = entityType == "link" ? "link.color.selected" : "list.color.selected" + SentrySDK.metrics.count( + key: metricKey, + value: 1, + unit: .generic("selection"), + attributes: [ + "color": color, + "entity_type": entityType + ] + ) + } + + /// Tracks symbol selection for links or lists. + /// - Parameters: + /// - symbol: The selected symbol raw value + /// - entityType: The type of entity ("link" or "list") + static func trackSymbolSelected(symbol: String, entityType: String) { + let metricKey = entityType == "link" ? "link.symbol.selected" : "list.symbol.selected" + SentrySDK.metrics.count( + key: metricKey, + value: 1, + unit: .generic("selection"), + attributes: [ + "symbol": symbol, + "entity_type": entityType + ] + ) + } + + // MARK: - Feedback + + /// Tracks feedback form opening. + static func trackFeedbackFormOpened() { + SentrySDK.metrics.count( + key: "feedback.form.opened", + value: 1, + unit: .generic("interaction") + ) + } + + /// Tracks feedback form closing. + static func trackFeedbackFormClosed() { + SentrySDK.metrics.count( + key: "feedback.form.closed", + value: 1, + unit: .generic("interaction") + ) + } + + // MARK: - Database Seeding + + /// Tracks database seeding start. + static func trackDatabaseSeedingStarted() { + SentrySDK.metrics.count( + key: "database.seeding.started", + value: 1, + unit: .generic("operation") + ) + } + + /// Tracks database seeding completion. + static func trackDatabaseSeedingCompleted() { + SentrySDK.metrics.count( + key: "database.seeding.completed", + value: 1, + unit: .generic("operation") + ) + } + + // MARK: - Performance Metrics + + /// Tracks QR code generation duration. + /// - Parameters: + /// - duration: Generation time in seconds + /// - cacheHit: Whether the QR code was served from cache + /// - imageSize: Image dimensions as string (e.g., "200x200") + static func trackQRCodeGenerationDuration(duration: Double, cacheHit: Bool, imageSize: String) { + SentrySDK.metrics.distribution( + key: "qr_code.generation.duration", + value: duration, + unit: .second, + attributes: [ + "cache_hit": cacheHit, + "image_size": imageSize + ] + ) + } + + /// Tracks QR code cache hit. + static func trackQRCodeCacheHit() { + SentrySDK.metrics.count( + key: "qr_code.cache.hit", + value: 1, + unit: .generic("hit") + ) + } + + /// Tracks QR code cache miss. + static func trackQRCodeCacheMiss() { + SentrySDK.metrics.count( + key: "qr_code.cache.miss", + value: 1, + unit: .generic("miss") + ) + } + + /// Tracks QR code cache eviction. + /// - Parameter reason: Reason for eviction ("memory_warning" | "size_limit") + static func trackQRCodeCacheEviction(reason: String) { + SentrySDK.metrics.count( + key: "qr_code.cache.eviction", + value: 1, + unit: .generic("eviction"), + attributes: [ + "reason": reason + ] + ) + } + + // MARK: - Error Rate Metrics + + /// Tracks error rate by type. + /// - Parameter errorType: Type of error ("persistence" | "qr_generation" | "nfc" | "validation" | "data_corruption") + static func trackErrorRate(errorType: String) { + SentrySDK.metrics.count( + key: "error.rate", + value: 1, + unit: .generic("error"), + attributes: [ + "error_type": errorType + ] + ) + } + + /// Captures an error and tracks error rate metric. + /// This is a convenience wrapper that both captures the error and tracks the metric. + /// - Parameter error: The error to capture + /// - Parameter configureScope: Optional scope configuration closure + static func captureErrorAndTrackRate(_ error: Error, configureScope: ((Scope) -> Void)? = nil) { + // Extract error type from AppError + let errorType: String + if let appError = error as? AppError { + switch appError { + case .persistenceError: + errorType = "persistence" + case .qrCodeGenerationError, .failedToGenerateQRCode: + errorType = "qr_generation" + case .nfcError: + errorType = "nfc" + case .validationError: + errorType = "validation" + case .dataCorruption: + errorType = "data_corruption" + case .networkError: + errorType = "network" + case .failedToOpenURL: + errorType = "url_opening" + case .unknownError: + errorType = "unknown" + } + } else { + errorType = "unknown" + } + + // Track error rate metric + trackErrorRate(errorType: errorType) + + // Capture error with Sentry + if let configureScope = configureScope { + SentrySDK.capture(error: error, configureScope: configureScope) + } else { + SentrySDK.capture(error: error) + } + } + + // MARK: - Search Metrics + + /// Tracks search performed. + /// - Parameters: + /// - searchContext: Context of search ("lists" | "links") + /// - resultCount: Number of results returned + static func trackSearchPerformed(searchContext: String, resultCount: Int) { + SentrySDK.metrics.count( + key: "search.performed", + value: 1, + unit: .generic("search"), + attributes: [ + "search_context": searchContext, + "result_count": resultCount + ] + ) + } + + /// Tracks search query length. + /// - Parameters: + /// - length: Length of search query + /// - searchContext: Context of search ("lists" | "links") + static func trackSearchQueryLength(length: Int, searchContext: String) { + SentrySDK.metrics.distribution( + key: "search.query.length", + value: Double(length), + unit: .generic("character"), + attributes: [ + "search_context": searchContext + ] + ) + } + + // MARK: - List Management + + /// Tracks list pinning action. + static func trackListPinned() { + SentrySDK.metrics.count( + key: "list.pinned", + value: 1, + unit: .generic("action") + ) + } + + /// Tracks list unpinning action. + static func trackListUnpinned() { + SentrySDK.metrics.count( + key: "list.unpinned", + value: 1, + unit: .generic("action") + ) + } + + /// Tracks list deletion. + /// - Parameter linkCount: Number of links in deleted list + static func trackListDeleted(linkCount: Int) { + SentrySDK.metrics.count( + key: "list.deleted", + value: 1, + unit: .generic("deletion"), + attributes: [ + "link_count": linkCount + ] + ) + } + + /// Tracks bulk list deletion. + /// - Parameter count: Number of lists deleted + static func trackListDeletedBulk(count: Int) { + SentrySDK.metrics.count( + key: "list.deleted.bulk", + value: 1, + unit: .generic("deletion"), + attributes: [ + "count": count + ] + ) + } + + // MARK: - Link Management + + /// Tracks link deletion. + /// - Parameter listLinkCount: Total links in list after deletion + static func trackLinkDeleted(listLinkCount: Int) { + SentrySDK.metrics.count( + key: "link.deleted", + value: 1, + unit: .generic("deletion"), + attributes: [ + "list_link_count": listLinkCount + ] + ) + } + + /// Tracks bulk link deletion. + /// - Parameters: + /// - count: Number of links deleted + /// - listId: UUID of the list + static func trackLinkDeletedBulk(count: Int, listId: String) { + SentrySDK.metrics.count( + key: "link.deleted.bulk", + value: 1, + unit: .generic("deletion"), + attributes: [ + "count": count, + "list_id": listId + ] + ) + } + + /// Tracks link opened in Safari. + /// - Parameters: + /// - linkId: The ID of the link being opened + /// - sharingMethod: Optional sharing method if opened from share context + static func trackLinkOpened(linkId: String, sharingMethod: String? = nil) { + var attributes: [String: String] = [ + "link_id": linkId + ] + if let sharingMethod = sharingMethod { + attributes["sharing_method"] = sharingMethod + } + SentrySDK.metrics.count( + key: "link.opened", + value: 1, + unit: .generic("interaction"), + attributes: attributes + ) + } + + // MARK: - Share Extension + + /// Tracks share extension opened. + /// - Parameter sourceApp: Optional source app identifier + static func trackShareExtensionOpened(sourceApp: String? = nil) { + var attributes: [String: String] = [:] + if let sourceApp = sourceApp { + attributes["source_app"] = sourceApp + } + SentrySDK.metrics.count( + key: "share_extension.opened", + value: 1, + unit: .generic("interaction"), + attributes: attributes.isEmpty ? nil : attributes + ) + } + + /// Tracks share extension completion. + /// - Parameters: + /// - listSelected: Whether user selected a list + /// - nameEdited: Whether user edited the name + static func trackShareExtensionCompleted(listSelected: Bool, nameEdited: Bool) { + SentrySDK.metrics.count( + key: "share_extension.completed", + value: 1, + unit: .generic("completion"), + attributes: [ + "list_selected": listSelected, + "name_edited": nameEdited + ] + ) + } + + /// Tracks share extension cancellation. + /// - Parameter step: Step where cancellation occurred ("initial" | "after_list_selection" | "after_name_edit") + static func trackShareExtensionCancelled(step: String) { + SentrySDK.metrics.count( + key: "share_extension.cancelled", + value: 1, + unit: .generic("cancellation"), + attributes: [ + "step": step + ] + ) + } + + // MARK: - NFC Metrics + + /// Tracks NFC availability (gauge). + static func trackNFCAvailable() { + SentrySDK.metrics.gauge( + key: "nfc.available", + value: 1.0, + unit: .generic("capability") + ) + } + + /// Tracks NFC share initiation. + static func trackNFCShareInitiated() { + SentrySDK.metrics.count( + key: "nfc.share.initiated", + value: 1, + unit: .generic("share") + ) + } + + /// Tracks successful NFC share. + static func trackNFCShareSuccess() { + SentrySDK.metrics.count( + key: "nfc.share.success", + value: 1, + unit: .generic("share") + ) + } + + /// Tracks failed NFC share. + /// - Parameter errorType: Optional error type category + static func trackNFCShareFailed(errorType: String? = nil) { + var attributes: [String: String] = [:] + if let errorType = errorType { + attributes["error_type"] = errorType + } + SentrySDK.metrics.count( + key: "nfc.share.failed", + value: 1, + unit: .generic("share"), + attributes: attributes.isEmpty ? nil : attributes + ) + } + + // MARK: - Database Performance + + /// Tracks database query duration. + /// - Parameters: + /// - duration: Query duration in seconds + /// - queryType: Type of query ("fetch_lists" | "fetch_links" | "save" | "delete") + static func trackDatabaseQueryDuration(duration: Double, queryType: String) { + SentrySDK.metrics.distribution( + key: "database.query.duration", + value: duration, + unit: .second, + attributes: [ + "query_type": queryType + ] + ) + } + + /// Tracks database save duration. + /// - Parameters: + /// - duration: Save duration in seconds + /// - entityType: Type of entity ("link" | "list") + /// - operation: Type of operation ("create" | "update" | "delete") + static func trackDatabaseSaveDuration(duration: Double, entityType: String, operation: String) { + SentrySDK.metrics.distribution( + key: "database.save.duration", + value: duration, + unit: .second, + attributes: [ + "entity_type": entityType, + "operation": operation + ] + ) + } + + // MARK: - App Health + + /// Tracks memory warning received. + /// - Parameter cacheSizeAtWarning: Number of items in cache when warning received + static func trackMemoryWarningReceived(cacheSizeAtWarning: Int) { + SentrySDK.metrics.count( + key: "memory.warning.received", + value: 1, + unit: .generic("warning"), + attributes: [ + "cache_size_at_warning": cacheSizeAtWarning + ] + ) + } +} +// swiftlint:enable type_body_length file_length diff --git a/Targets/ShareExtension/Sources/ShareViewController.swift b/Targets/ShareExtension/Sources/ShareViewController.swift index 93b4f45..af62809 100644 --- a/Targets/ShareExtension/Sources/ShareViewController.swift +++ b/Targets/ShareExtension/Sources/ShareViewController.swift @@ -35,6 +35,17 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable /// The lists that can be selected in the share extension. private var lists: Result<[LinkListModel], Error>? + // MARK: - Share Extension State Tracking + + /// Whether a list was selected (for metrics) + private var wasListSelected = false + + /// Whether the name was edited (for metrics) + private var wasNameEdited = false + + /// Initial name value to detect edits + private var initialName: String? + // MARK: - Instance Life Cycle required init?(coder: NSCoder) { @@ -58,6 +69,13 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable override func viewDidLoad() { super.viewDidLoad() + // Track share extension opened + SentrySDK.metrics.count( + key: "share_extension.opened", + value: 1, + unit: .generic("interaction") + ) + setupUI() loadShareItem() } @@ -81,7 +99,7 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable /// This method is defined as `private static` to because it is called from a non-mutating context. /// /// - Parameter options: Options structure to configure Sentry. - private static func configureSentry(options: Options) { + private static func configureSentry(options: Options) { // swiftlint:disable:this function_body_length // Disable Sentry for tests because it produces a lot of noise. if ProcessInfo.processInfo.isTestingEnabled { Self.logger.warning("Sentry is disabled in test environment") @@ -155,6 +173,7 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable // Configure Other Options options.experimental.enableUnhandledCPPExceptionsV2 = false + options.experimental.enableMetrics = true } private func setupModelContainer() { @@ -224,16 +243,18 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable ] SentrySDK.addBreadcrumb(breadcrumb) - // Analytics: list_created (no PII) - let event = Event(level: .info) - event.message = SentryMessage(formatted: "list_created") - event.extra = [ - "list_id": defaultList.id.uuidString, - "entity_type": "list", - "creation_flow": "share_extension", - "auto_created": true - ] - SentrySDK.capture(event: event) + // Track list creation using metrics - better for aggregate counts than individual events + SentrySDK.metrics.count( + key: "list.created", + value: 1, + unit: .generic("list"), + attributes: [ + "list_id": defaultList.id.uuidString, + "entity_type": "list", + "creation_flow": "share_extension", + "auto_created": true + ] + ) } self.lists = .success(lists) @@ -310,6 +331,7 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable guard let self = self else { return } self.name = name + self.initialName = name // Store initial name to detect edits self.rawUrl = url.absoluteString // Show the URL @@ -472,15 +494,29 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable ] SentrySDK.addBreadcrumb(breadcrumb) - let event = Event(level: .info) - event.message = SentryMessage(formatted: "link_created") - event.extra = [ - "link_id": newLink.id.uuidString, - "list_id": list.id.uuidString, - "entity_type": "link", - "creation_flow": "share_extension" - ] - SentrySDK.capture(event: event) + // Track link creation using metrics - better for aggregate counts than individual events + SentrySDK.metrics.count( + key: "link.created", + value: 1, + unit: .generic("link"), + attributes: [ + "link_id": newLink.id.uuidString, + "list_id": list.id.uuidString, + "entity_type": "link", + "creation_flow": "share_extension" + ] + ) + + // Track share extension completion + SentrySDK.metrics.count( + key: "share_extension.completed", + value: 1, + unit: .generic("completion"), + attributes: [ + "list_selected": wasListSelected, + "name_edited": wasNameEdited + ] + ) // Post an accessibility announcement to inform users, especially those using VoiceOver or other assistive technologies, // that the link has been successfully saved. This ensures that users with visual impairments receive immediate, audible feedback @@ -514,6 +550,26 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable breadcrumb.message = "User cancelled share extension" SentrySDK.addBreadcrumb(breadcrumb) + // Determine cancellation step + let step: String + if wasNameEdited { + step = "after_name_edit" + } else if wasListSelected { + step = "after_list_selection" + } else { + step = "initial" + } + + // Track share extension cancellation + SentrySDK.metrics.count( + key: "share_extension.cancelled", + value: 1, + unit: .generic("cancellation"), + attributes: [ + "step": step + ] + ) + // Post an accessibility announcement to inform users that the share action was cancelled. // This is especially useful for users relying on VoiceOver or other assistive technologies, // ensuring they receive immediate, audible feedback about the cancellation. @@ -558,6 +614,10 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable controller.initialText = self.name ?? "" controller.onSave = { text in self.name = text + // Track if name was edited (different from initial name) + if let initialName = self.initialName, text != initialName { + self.wasNameEdited = true + } self.reloadConfigurationItems() self.validateContent() } @@ -600,6 +660,7 @@ class ShareViewController: SLComposeServiceViewController { // swiftlint:disable let controller = ItemPickerViewController(options: items, selected: self.selectedList?.id) controller.onSelect = { itemId in self.selectedList = lists.first(where: { $0.id == itemId }) + self.wasListSelected = true // Track that user selected a list self.reloadConfigurationItems() self.validateContent() } From b0189e53e93ae9d3c0cf19b7446912b1e0af5fae Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 21 Jan 2026 16:31:42 +0100 Subject: [PATCH 2/4] Fix compilation --- .../Resources/Settings.bundle/Licenses.latest_result.txt | 4 ++-- .../App/Sources/Resources/Settings.bundle/Licenses.plist | 2 +- Targets/App/Sources/Utils/SentryMetricsHelper.swift | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Targets/App/Sources/Resources/Settings.bundle/Licenses.latest_result.txt b/Targets/App/Sources/Resources/Settings.bundle/Licenses.latest_result.txt index cd8c580..49d31d4 100644 --- a/Targets/App/Sources/Resources/Settings.bundle/Licenses.latest_result.txt +++ b/Targets/App/Sources/Resources/Settings.bundle/Licenses.latest_result.txt @@ -1,12 +1,12 @@ name: OnLaunch-iOS-Client, nameSpecified: OnLaunch-iOS-Client, owner: kula-app, version: 0.0.6, source: https://github.com/kula-app/OnLaunch-iOS-Client -name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.1.0, source: https://github.com/getsentry/sentry-cocoa +name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.2.0, source: https://github.com/getsentry/sentry-cocoa name: SFSafeSymbols, nameSpecified: SFSafeSymbols, owner: SFSafeSymbols, version: 7.0.0, source: https://github.com/SFSafeSymbols/SFSafeSymbols name: OnLaunch-iOS-Client, nameSpecified: OnLaunch-iOS-Client, owner: kula-app, version: 0.0.6, source: https://github.com/kula-app/OnLaunch-iOS-Client -name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.1.0, source: https://github.com/getsentry/sentry-cocoa +name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.2.0, source: https://github.com/getsentry/sentry-cocoa name: SFSafeSymbols, nameSpecified: SFSafeSymbols, owner: SFSafeSymbols, version: 7.0.0, source: https://github.com/SFSafeSymbols/SFSafeSymbols diff --git a/Targets/App/Sources/Resources/Settings.bundle/Licenses.plist b/Targets/App/Sources/Resources/Settings.bundle/Licenses.plist index 7fa459a..e04fc69 100644 --- a/Targets/App/Sources/Resources/Settings.bundle/Licenses.plist +++ b/Targets/App/Sources/Resources/Settings.bundle/Licenses.plist @@ -22,7 +22,7 @@ File Licenses/sentry-cocoa Title - Sentry (9.1.0) + Sentry (9.2.0) Type PSChildPaneSpecifier diff --git a/Targets/App/Sources/Utils/SentryMetricsHelper.swift b/Targets/App/Sources/Utils/SentryMetricsHelper.swift index 277cb50..effa9e3 100644 --- a/Targets/App/Sources/Utils/SentryMetricsHelper.swift +++ b/Targets/App/Sources/Utils/SentryMetricsHelper.swift @@ -1,6 +1,7 @@ // swiftlint:disable type_body_length file_length import Foundation import Sentry +import FlinkyCore /// Helper utility for tracking analytics metrics using Sentry Metrics API. /// @@ -254,7 +255,7 @@ enum SentryMetricsHelper { // Capture error with Sentry if let configureScope = configureScope { - SentrySDK.capture(error: error, configureScope: configureScope) + SentrySDK.capture(error: error, block: configureScope) } else { SentrySDK.capture(error: error) } @@ -402,7 +403,7 @@ enum SentryMetricsHelper { key: "share_extension.opened", value: 1, unit: .generic("interaction"), - attributes: attributes.isEmpty ? nil : attributes + attributes: attributes ) } @@ -475,7 +476,7 @@ enum SentryMetricsHelper { key: "nfc.share.failed", value: 1, unit: .generic("share"), - attributes: attributes.isEmpty ? nil : attributes + attributes: attributes ) } From c0e1e5f1013aa6f07e51ed7ff21cd0daf6738eb9 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 21 Jan 2026 16:44:59 +0100 Subject: [PATCH 3/4] Added more app health metrics --- Documentation/Analytics.md | 66 +++- Targets/App/Sources/Main/FlinkyApp.swift | 5 + .../Sources/Services/AppHealthObserver.swift | 275 ++++++++++++++ .../App/Sources/Services/QRCodeCache.swift | 3 +- .../Sources/Utils/SentryMetricsHelper.swift | 356 ++++++++++++++++-- 5 files changed, 669 insertions(+), 36 deletions(-) create mode 100644 Targets/App/Sources/Services/AppHealthObserver.swift diff --git a/Documentation/Analytics.md b/Documentation/Analytics.md index 5059c22..138496a 100644 --- a/Documentation/Analytics.md +++ b/Documentation/Analytics.md @@ -123,6 +123,29 @@ struct LinkDetailContainerView: View { Sentry Metrics provide aggregate counters for tracking user behavior patterns. Unlike individual events, metrics don't create "issues" in Sentry and are better suited for analytics that only need aggregate counts. Metrics are enabled by default in Sentry SDK 9.2.0+. +### Avoiding Overlap with Tracing + +Before adding new metrics, verify they don't duplicate what Sentry already tracks automatically. Reference: [sentry-cocoa#7000](https://github.com/getsentry/sentry-cocoa/issues/7000) + +**Already Tracked via Tracing (DO NOT DUPLICATE):** + +- App Start (cold/warm start duration) +- UIViewController load times +- Slow/Frozen frames +- Network request performance (NSURLSession) +- File I/O operations (NSData, NSFileManager) +- Core Data fetch/save +- User interaction clicks +- Time to Initial/Full Display (TTID/TTFD) + +**Already Tracked via Events/Sessions:** + +- App Hangs (main thread blocking) +- Watchdog Terminations (OOM, system kills) +- MetricKit diagnostics +- Release health (crash-free sessions) +- HTTP client errors (4xx/5xx) + ### When to Use Metrics vs Events **Use Metrics For:** @@ -131,6 +154,7 @@ Sentry Metrics provide aggregate counters for tracking user behavior patterns. U - Feature usage tracking - Aggregate behavior patterns - Analytics that only need counts +- System health signals (memory warnings, thermal state, network changes) **Use Events For:** @@ -276,6 +300,26 @@ The following metrics are currently tracked via `SentryMetricsHelper`: - `database.seeding.started`: Counter for database seeding start - `database.seeding.completed`: Counter for database seeding completion +#### App Health Metrics (System-Level Signals) + +These metrics track system-level signals that don't overlap with Sentry's automatic tracing. They provide quick app/runtime health insights. + +- `memory.warning.received`: Counter for memory warnings (attributes: `cache_size_at_warning`, `app_state`) +- `device.thermal.transition`: Counter for thermal state changes (attributes: `from_state`, `to_state`, `is_escalation`) +- `network.reachability.changed`: Counter for network connectivity changes (attributes: `status`, `interface`, `is_expensive`, `is_constrained`) +- `app.state.transition`: Counter for app state transitions (attributes: `to_state`, `from_state`) + +#### Background Task Metrics + +- `background.task.completed`: Counter for completed background tasks (attributes: `task_type`, `task_identifier`) +- `background.task.expired`: Counter for expired background tasks (attributes: `task_type`, `time_remaining`) +- `background.task.duration`: Distribution of background task duration (attributes: `task_type`, `outcome`) + +#### Link Metadata Metrics + +- `link.metadata.fetched`: Counter for link metadata fetches (attributes: `outcome`, `has_image`, `has_video`, `content_type`) +- `link.metadata.fetch.duration`: Distribution of metadata fetch duration (attributes: `outcome`) + ### Recommended Development Metrics The following metrics are recommended for future implementation to gain deeper insights into app performance, user behavior, and feature adoption. These are organized by priority and category. @@ -341,13 +385,25 @@ The following metrics are recommended for future implementation to gain deeper i - `flow.creation.completed` (Counter) - Track completed creation flows - Attributes: `flow_type`, `duration_seconds` -**App Health Metrics** +**App Health Metrics** ✅ IMPLEMENTED + +The following metrics are now implemented via `AppHealthObserver`: + +- ✅ `memory.warning.received` (Counter) - Track memory warnings + - Attributes: `cache_size_at_warning` (number), `app_state` (string) +- ✅ `device.thermal.transition` (Counter) - Track thermal state changes + - Attributes: `from_state`, `to_state`, `is_escalation` + - Reference: [sentry-cocoa#7000](https://github.com/getsentry/sentry-cocoa/issues/7000) +- ✅ `network.reachability.changed` (Counter) - Track network connectivity changes + - Attributes: `status`, `interface`, `is_expensive`, `is_constrained` + - Reference: [sentry-cocoa#7000](https://github.com/getsentry/sentry-cocoa/issues/7000) +- ✅ `app.state.transition` (Counter) - Track foreground/background transitions + - Attributes: `to_state`, `from_state` + +**Still TODO:** -- `app.launch` (Counter) - Track app launches - - Attributes: `is_cold_start` (boolean) +- `app.launch` (Counter) - Track app launches (overlaps with App Start Tracing, consider if needed) - `app.session.duration` (Distribution) - Track session duration -- `memory.warning.received` (Counter) - Track memory warnings - - Attributes: `cache_size_at_warning` (number) **Data Health Metrics** diff --git a/Targets/App/Sources/Main/FlinkyApp.swift b/Targets/App/Sources/Main/FlinkyApp.swift index da64ec9..1b6e62e 100644 --- a/Targets/App/Sources/Main/FlinkyApp.swift +++ b/Targets/App/Sources/Main/FlinkyApp.swift @@ -17,6 +17,11 @@ struct FlinkyApp: App { Self.configureSentry(options: options) } + // Start app health observation for system-level metrics + // (thermal state, network reachability, app state transitions) + // Reference: https://github.com/getsentry/sentry-cocoa/issues/7000 + AppHealthObserver.shared.startObserving() + do { sharedModelContainer = try SharedModelContainerFactory.make( isStoredInMemoryOnly: ProcessInfo.processInfo.isTestingEnabled diff --git a/Targets/App/Sources/Services/AppHealthObserver.swift b/Targets/App/Sources/Services/AppHealthObserver.swift new file mode 100644 index 0000000..cb646ef --- /dev/null +++ b/Targets/App/Sources/Services/AppHealthObserver.swift @@ -0,0 +1,275 @@ +import Foundation +import Network +import os.log +import Sentry +import UIKit + +/// Observes app health signals and reports them as Sentry metrics. +/// +/// This class monitors: +/// - **Thermal state changes**: Track when the device heats up or cools down +/// - **Network reachability changes**: Track connectivity state transitions +/// - **App state transitions**: Track foreground/background transitions +/// +/// These metrics are recommended by the Sentry SDK team for app health monitoring +/// that doesn't overlap with automatic tracing features. +/// +/// Reference: https://github.com/getsentry/sentry-cocoa/issues/7000 +final class AppHealthObserver { + + // MARK: - Singleton + + /// Shared instance of the app health observer. + static let shared = AppHealthObserver() + + // MARK: - Properties + + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Flinky", category: "AppHealthObserver") + + private var previousThermalState: ProcessInfo.ThermalState = ProcessInfo.processInfo.thermalState + private var previousAppState: UIApplication.State = .inactive + private var previousNetworkStatus: NWPath.Status? + private var previousNetworkInterface: String? + + private let networkMonitor = NWPathMonitor() + private let networkQueue = DispatchQueue(label: "com.flinky.network-monitor") + + private var isObserving = false + + // MARK: - Initialization + + private init() {} + + // MARK: - Public Methods + + /// Starts observing app health signals. + /// + /// Call this method once during app initialization, typically in `FlinkyApp.init()`. + func startObserving() { + guard !isObserving else { + Self.logger.warning("AppHealthObserver is already observing") + return + } + + isObserving = true + Self.logger.info("Starting app health observation") + + setupThermalStateObserver() + setupNetworkReachabilityObserver() + setupAppStateObserver() + } + + /// Stops observing app health signals. + /// + /// Call this method during app cleanup if needed. + func stopObserving() { + guard isObserving else { return } + + isObserving = false + Self.logger.info("Stopping app health observation") + + // swiftlint:disable:next notification_center_detachment + NotificationCenter.default.removeObserver(self) + networkMonitor.cancel() + } + + // MARK: - Thermal State Observation + + private func setupThermalStateObserver() { + previousThermalState = ProcessInfo.processInfo.thermalState + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleThermalStateChange), + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil + ) + + Self.logger.debug("Thermal state observer setup complete. Initial state: \(self.thermalStateString(self.previousThermalState))") + } + + @objc private func handleThermalStateChange() { + let newState = ProcessInfo.processInfo.thermalState + let oldState = previousThermalState + + guard newState != oldState else { return } + + let fromString = thermalStateString(oldState) + let toString = thermalStateString(newState) + let isEscalation = newState.rawValue > oldState.rawValue + + Self.logger.info("Thermal state changed: \(fromString) → \(toString) (escalation: \(isEscalation))") + + // Track thermal state transition + SentryMetricsHelper.trackThermalStateTransition( + fromState: fromString, + toState: toString, + isEscalation: isEscalation + ) + + // Add breadcrumb for debugging context + let breadcrumb = Breadcrumb(level: isEscalation ? .warning : .info, category: "device_health") + breadcrumb.message = "Thermal state changed: \(fromString) → \(toString)" + breadcrumb.data = [ + "from_state": fromString, + "to_state": toString, + "is_escalation": isEscalation + ] + SentrySDK.addBreadcrumb(breadcrumb) + + previousThermalState = newState + } + + private func thermalStateString(_ state: ProcessInfo.ThermalState) -> String { + SentryMetricsHelper.thermalStateString(state) + } + + // MARK: - Network Reachability Observation + + private func setupNetworkReachabilityObserver() { + networkMonitor.pathUpdateHandler = { [weak self] path in + self?.handleNetworkPathUpdate(path) + } + + networkMonitor.start(queue: networkQueue) + + Self.logger.debug("Network reachability observer setup complete") + } + + private func handleNetworkPathUpdate(_ path: NWPath) { + let status = path.status + let interfaceType = getInterfaceType(path) + let statusString = status == .satisfied ? "connected" : "disconnected" + + // Only track changes, not initial state + guard previousNetworkStatus != nil else { + previousNetworkStatus = status + previousNetworkInterface = interfaceType + Self.logger.debug("Initial network state: \(statusString) via \(interfaceType)") + return + } + + // Check if there's an actual change + let statusChanged = previousNetworkStatus != status + let interfaceChanged = previousNetworkInterface != interfaceType + + guard statusChanged || interfaceChanged else { return } + + Self.logger.info("Network changed: \(statusString) via \(interfaceType) (expensive: \(path.isExpensive), constrained: \(path.isConstrained))") + + // Track network reachability change + SentryMetricsHelper.trackNetworkReachabilityChanged( + status: statusString, + interfaceType: interfaceType, + isExpensive: path.isExpensive, + isConstrained: path.isConstrained + ) + + // Add breadcrumb for debugging context + let breadcrumb = Breadcrumb(level: .info, category: "network") + breadcrumb.message = "Network changed: \(statusString) via \(interfaceType)" + breadcrumb.data = [ + "status": statusString, + "interface": interfaceType, + "is_expensive": path.isExpensive, + "is_constrained": path.isConstrained + ] + SentrySDK.addBreadcrumb(breadcrumb) + + previousNetworkStatus = status + previousNetworkInterface = interfaceType + } + + private func getInterfaceType(_ path: NWPath) -> String { + if path.usesInterfaceType(.wifi) { + return "wifi" + } else if path.usesInterfaceType(.cellular) { + return "cellular" + } else if path.usesInterfaceType(.wiredEthernet) { + return "wired" + } else if path.usesInterfaceType(.loopback) { + return "loopback" + } else { + return "other" + } + } + + // MARK: - App State Observation + + private func setupAppStateObserver() { + previousAppState = UIApplication.shared.applicationState + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + + Self.logger.debug("App state observer setup complete. Initial state: \(self.appStateString(self.previousAppState))") + } + + @objc private func handleAppDidBecomeActive() { + trackAppStateTransition(to: .active) + } + + @objc private func handleAppWillResignActive() { + trackAppStateTransition(to: .inactive) + } + + @objc private func handleAppDidEnterBackground() { + trackAppStateTransition(to: .background) + } + + @objc private func handleAppWillEnterForeground() { + // This is called before didBecomeActive, we'll track the full transition there + Self.logger.debug("App will enter foreground") + } + + private func trackAppStateTransition(to newState: UIApplication.State) { + let oldState = previousAppState + + guard newState != oldState else { return } + + let fromString = appStateString(oldState) + let toString = appStateString(newState) + + Self.logger.info("App state changed: \(fromString) → \(toString)") + + // Track app state transition + SentryMetricsHelper.trackAppStateTransition(toState: toString, fromState: fromString) + + previousAppState = newState + } + + private func appStateString(_ state: UIApplication.State) -> String { + SentryMetricsHelper.appStateString(state) + } + + // MARK: - Cleanup + + deinit { + stopObserving() + } +} diff --git a/Targets/App/Sources/Services/QRCodeCache.swift b/Targets/App/Sources/Services/QRCodeCache.swift index c07e260..03deb8e 100644 --- a/Targets/App/Sources/Services/QRCodeCache.swift +++ b/Targets/App/Sources/Services/QRCodeCache.swift @@ -42,7 +42,8 @@ class QRCodeCache: NSObject { @objc private func handleMemoryWarning() { Self.logger.warning("Received memory warning, clearing QR code cache") let cacheSize = storage.countLimit - SentryMetricsHelper.trackMemoryWarningReceived(cacheSizeAtWarning: cacheSize) + let appState = UIApplication.shared.applicationState == .active ? "foreground" : "background" + SentryMetricsHelper.trackMemoryWarningReceived(cacheSizeAtWarning: cacheSize, appState: appState) clearCache() } diff --git a/Targets/App/Sources/Utils/SentryMetricsHelper.swift b/Targets/App/Sources/Utils/SentryMetricsHelper.swift index effa9e3..1475b00 100644 --- a/Targets/App/Sources/Utils/SentryMetricsHelper.swift +++ b/Targets/App/Sources/Utils/SentryMetricsHelper.swift @@ -1,15 +1,186 @@ // swiftlint:disable type_body_length file_length +import FlinkyCore import Foundation import Sentry -import FlinkyCore + +// MARK: - SentryMetricsHelper /// Helper utility for tracking analytics metrics using Sentry Metrics API. /// -/// This utility provides type-safe wrapper functions for common metric patterns, -/// replacing the previous `SentrySDK.capture(event:)` calls for analytics tracking. -/// Metrics are better suited for tracking aggregate counts and don't create individual "issues" in Sentry. +/// This utility provides type-safe wrapper functions for metric patterns that +/// **DO NOT overlap with Sentry's automatic tracing**. Before adding new metrics, +/// verify they don't duplicate what's already tracked automatically. enum SentryMetricsHelper { + + // MARK: - App Health Signals + // These track system-level signals that don't overlap with tracing. + // They provide quick app/runtime health insights. + + /// Tracks memory warning received from the system. + /// + /// **Why Metrics (not Events):** + /// - High-volume signal (can happen frequently under pressure) + /// - Aggregate count is more valuable than individual occurrences + /// - Doesn't need stack traces (unlike OOM crashes) + /// + /// **Not Covered By:** + /// - Watchdog Terminations (those track the final crash, not warnings) + /// - MetricKit (doesn't expose memory warnings) + /// + /// - Parameter cacheSizeAtWarning: Number of items in cache when warning received + /// - Parameter appState: Current app state ("foreground" or "background") + static func trackMemoryWarningReceived(cacheSizeAtWarning: Int, appState: String = "foreground") { + SentrySDK.metrics.count( + key: "memory.warning.received", + value: 1, + unit: .generic("warning"), + attributes: [ + "cache_size_at_warning": String(cacheSizeAtWarning), + "app_state": appState + ] + ) + } + + /// Tracks thermal state transitions. + /// + /// **Why Metrics:** + /// - Thermal throttling affects performance but isn't captured by tracing + /// - Aggregate patterns are useful (e.g., "20% of sessions reach serious thermal state") + /// + /// **Not Covered By:** + /// - Any existing Sentry feature + /// - Partially related to App Hangs (thermal throttling can cause slowdowns), but distinct + /// + /// - Parameters: + /// - fromState: Previous thermal state ("nominal", "fair", "serious", "critical") + /// - toState: New thermal state after transition + /// - isEscalation: True if thermal state worsened + static func trackThermalStateTransition(fromState: String, toState: String, isEscalation: Bool) { + SentrySDK.metrics.count( + key: "device.thermal.transition", + value: 1, + unit: .generic("transition"), + attributes: [ + "from_state": fromState, + "to_state": toState, + "is_escalation": String(isEscalation) + ] + ) + } + + /// Tracks network reachability changes. + /// + /// **Why Metrics:** + /// - Network changes correlate with errors but aren't the errors themselves + /// - Helps contextualize "why did errors spike?" + /// + /// **Not Covered By:** + /// - Network Tracking (which tracks request performance, not connectivity state) + /// - HTTP Client Errors (which track failures, not connectivity) + /// + /// - Parameters: + /// - status: "connected" or "disconnected" + /// - interfaceType: "wifi", "cellular", "wired", "loopback", "other" + /// - isExpensive: True if on cellular (metered connection) + /// - isConstrained: True if Low Data Mode is enabled + static func trackNetworkReachabilityChanged( + status: String, + interfaceType: String, + isExpensive: Bool, + isConstrained: Bool + ) { + SentrySDK.metrics.count( + key: "network.reachability.changed", + value: 1, + unit: .generic("transition"), + attributes: [ + "status": status, + "interface": interfaceType, + "is_expensive": String(isExpensive), + "is_constrained": String(isConstrained) + ] + ) + } + + /// Tracks app state transitions (foreground/background). + /// + /// **Why Metrics:** + /// - Helps correlate other metrics with app state + /// - Useful for understanding user session patterns + /// + /// **Not Covered By:** + /// - App Start Tracing (only tracks initial launch, not subsequent transitions) + /// + /// - Parameters: + /// - toState: New app state ("active", "inactive", "background") + /// - fromState: Previous app state + static func trackAppStateTransition(toState: String, fromState: String) { + SentrySDK.metrics.count( + key: "app.state.transition", + value: 1, + unit: .generic("transition"), + attributes: [ + "to_state": toState, + "from_state": fromState + ] + ) + } + + // MARK: - Background Task Lifecycle + // Track background task completions, expirations, and failures. + // Not covered by Tracing (which focuses on foreground operations). + + /// Tracks background task completion. + /// - Parameters: + /// - taskType: Type of task ("app_refresh", "processing", "legacy") + /// - taskIdentifier: The registered task identifier + static func trackBackgroundTaskCompleted(taskType: String, taskIdentifier: String) { + SentrySDK.metrics.count( + key: "background.task.completed", + value: 1, + unit: .generic("task"), + attributes: [ + "task_type": taskType, + "task_identifier": taskIdentifier + ] + ) + } + + /// Tracks background task expiration. + /// - Parameters: + /// - taskType: Type of task ("app_refresh", "processing", "legacy") + /// - timeRemaining: Seconds remaining when task expired + static func trackBackgroundTaskExpired(taskType: String, timeRemaining: Double) { + SentrySDK.metrics.count( + key: "background.task.expired", + value: 1, + unit: .generic("task"), + attributes: [ + "task_type": taskType, + "time_remaining": String(format: "%.1f", timeRemaining) + ] + ) + } + + /// Tracks background task duration. + /// - Parameters: + /// - duration: Task duration in seconds + /// - taskType: Type of task ("app_refresh", "processing", "legacy") + /// - outcome: Result of the task ("completed", "expired", "cancelled") + static func trackBackgroundTaskDuration(duration: Double, taskType: String, outcome: String) { + SentrySDK.metrics.distribution( + key: "background.task.duration", + value: duration, + unit: .second, + attributes: [ + "task_type": taskType, + "outcome": outcome + ] + ) + } + // MARK: - Link Creation + // User action tracking - doesn't overlap with tracing. /// Tracks link creation with creation flow and entity type attributes. /// - Parameters: @@ -42,7 +213,7 @@ enum SentryMetricsHelper { attributes: [ "creation_flow": creationFlow, "entity_type": "list", - "auto_created": autoCreated + "auto_created": String(autoCreated) ] ) } @@ -155,7 +326,10 @@ enum SentryMetricsHelper { ) } - // MARK: - Performance Metrics + // MARK: - QR Code Performance + // Performance metrics for QR code generation and caching. + // Note: This doesn't overlap with File I/O Tracing since QR generation + // is CPU-bound image processing, not file operations. /// Tracks QR code generation duration. /// - Parameters: @@ -168,7 +342,7 @@ enum SentryMetricsHelper { value: duration, unit: .second, attributes: [ - "cache_hit": cacheHit, + "cache_hit": String(cacheHit), "image_size": imageSize ] ) @@ -205,6 +379,49 @@ enum SentryMetricsHelper { ) } + // MARK: - Link Metadata (LinkPresentation Framework) + // Track link metadata fetching for rich previews. + + /// Tracks link metadata fetch completion. + /// - Parameters: + /// - outcome: Result of fetch ("success", "failed", "cached") + /// - hasImage: Whether the metadata includes an image + /// - hasVideo: Whether the metadata includes video + /// - contentType: Type of content ("article", "video", "audio", "generic") + static func trackLinkMetadataFetched( + outcome: String, + hasImage: Bool, + hasVideo: Bool, + contentType: String + ) { + SentrySDK.metrics.count( + key: "link.metadata.fetched", + value: 1, + unit: .generic("fetch"), + attributes: [ + "outcome": outcome, + "has_image": String(hasImage), + "has_video": String(hasVideo), + "content_type": contentType + ] + ) + } + + /// Tracks link metadata fetch duration. + /// - Parameters: + /// - duration: Fetch duration in seconds + /// - outcome: Result of fetch ("success", "failed", "cached") + static func trackLinkMetadataFetchDuration(duration: Double, outcome: String) { + SentrySDK.metrics.distribution( + key: "link.metadata.fetch.duration", + value: duration, + unit: .second, + attributes: [ + "outcome": outcome + ] + ) + } + // MARK: - Error Rate Metrics /// Tracks error rate by type. @@ -274,7 +491,7 @@ enum SentryMetricsHelper { unit: .generic("search"), attributes: [ "search_context": searchContext, - "result_count": resultCount + "result_count": String(resultCount) ] ) } @@ -322,7 +539,7 @@ enum SentryMetricsHelper { value: 1, unit: .generic("deletion"), attributes: [ - "link_count": linkCount + "link_count": String(linkCount) ] ) } @@ -335,7 +552,7 @@ enum SentryMetricsHelper { value: 1, unit: .generic("deletion"), attributes: [ - "count": count + "count": String(count) ] ) } @@ -350,7 +567,7 @@ enum SentryMetricsHelper { value: 1, unit: .generic("deletion"), attributes: [ - "list_link_count": listLinkCount + "list_link_count": String(listLinkCount) ] ) } @@ -365,7 +582,7 @@ enum SentryMetricsHelper { value: 1, unit: .generic("deletion"), attributes: [ - "count": count, + "count": String(count), "list_id": listId ] ) @@ -417,8 +634,8 @@ enum SentryMetricsHelper { value: 1, unit: .generic("completion"), attributes: [ - "list_selected": listSelected, - "name_edited": nameEdited + "list_selected": String(listSelected), + "name_edited": String(nameEdited) ] ) } @@ -437,15 +654,7 @@ enum SentryMetricsHelper { } // MARK: - NFC Metrics - - /// Tracks NFC availability (gauge). - static func trackNFCAvailable() { - SentrySDK.metrics.gauge( - key: "nfc.available", - value: 1.0, - unit: .generic("capability") - ) - } + // CoreNFC framework usage tracking. /// Tracks NFC share initiation. static func trackNFCShareInitiated() { @@ -480,7 +689,27 @@ enum SentryMetricsHelper { ) } + /// Tracks NFC operation performed. + /// - Parameters: + /// - operation: Type of operation ("read", "write", "scan") + /// - tagType: Type of NFC tag ("ndef", "iso7816", "iso15693", "felica") + /// - outcome: Result of operation ("success", "failed", "cancelled") + static func trackNFCOperationPerformed(operation: String, tagType: String, outcome: String) { + SentrySDK.metrics.count( + key: "nfc.operation.performed", + value: 1, + unit: .generic("operation"), + attributes: [ + "operation": operation, + "tag_type": tagType, + "outcome": outcome + ] + ) + } + // MARK: - Database Performance + // Note: SwiftData is used (not Core Data), so these don't overlap with + // Sentry's Core Data Tracing which specifically instruments NSManagedObjectContext. /// Tracks database query duration. /// - Parameters: @@ -514,19 +743,86 @@ enum SentryMetricsHelper { ) } - // MARK: - App Health + // MARK: - Spotlight Search (CoreSpotlight) + // Track Spotlight indexing for searchable links. - /// Tracks memory warning received. - /// - Parameter cacheSizeAtWarning: Number of items in cache when warning received - static func trackMemoryWarningReceived(cacheSizeAtWarning: Int) { + /// Tracks Spotlight item indexed. + /// - Parameters: + /// - count: Number of items indexed + /// - contentType: Type of content indexed + static func trackSpotlightItemIndexed(count: Int, contentType: String) { SentrySDK.metrics.count( - key: "memory.warning.received", + key: "spotlight.item.indexed", + value: UInt(count), + unit: .generic("item"), + attributes: [ + "content_type": contentType + ] + ) + } + + /// Tracks Spotlight search performed from system. + /// - Parameters: + /// - resultCountBucket: Bucketed result count ("0", "1-10", "10-50", "50+") + /// - querySource: Source of query ("in_app", "system_spotlight") + static func trackSpotlightSearchPerformed(resultCountBucket: String, querySource: String) { + SentrySDK.metrics.count( + key: "spotlight.search.performed", value: 1, - unit: .generic("warning"), + unit: .generic("search"), attributes: [ - "cache_size_at_warning": cacheSizeAtWarning + "result_count_bucket": resultCountBucket, + "query_source": querySource ] ) } } + +// MARK: - Thermal State Helpers + +extension SentryMetricsHelper { + + /// Converts ProcessInfo.ThermalState to a string for metrics. + /// - Parameter state: The thermal state to convert + /// - Returns: String representation ("nominal", "fair", "serious", "critical") + static func thermalStateString(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: + return "nominal" + case .fair: + return "fair" + case .serious: + return "serious" + case .critical: + return "critical" + @unknown default: + return "unknown" + } + } +} + +// MARK: - App State Helpers + +#if canImport(UIKit) +import UIKit + +extension SentryMetricsHelper { + + /// Converts UIApplication.State to a string for metrics. + /// - Parameter state: The application state to convert + /// - Returns: String representation ("active", "inactive", "background") + static func appStateString(_ state: UIApplication.State) -> String { + switch state { + case .active: + return "active" + case .inactive: + return "inactive" + case .background: + return "background" + @unknown default: + return "unknown" + } + } +} +#endif // swiftlint:enable type_body_length file_length From dcb3fd90b3975db13c1473ef7d857eecbb1840e9 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 21 Jan 2026 16:57:01 +0100 Subject: [PATCH 4/4] Remove unused previousSearchText state variable from LinkListDetailContainerView and LinkListsContainerView --- .../Sources/UI/LinkListDetail/LinkListDetailContainerView.swift | 2 -- Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift | 2 -- 2 files changed, 4 deletions(-) diff --git a/Targets/App/Sources/UI/LinkListDetail/LinkListDetailContainerView.swift b/Targets/App/Sources/UI/LinkListDetail/LinkListDetailContainerView.swift index b0b327b..d3475e2 100644 --- a/Targets/App/Sources/UI/LinkListDetail/LinkListDetailContainerView.swift +++ b/Targets/App/Sources/UI/LinkListDetail/LinkListDetailContainerView.swift @@ -18,7 +18,6 @@ struct LinkListDetailContainerView: View { @State private var presentedLink: LinkModel? @State private var editingLink: LinkModel? @State private var searchText = "" - @State private var previousSearchText = "" @State private var linkToDelete: LinkModel? @State private var isDeleteLinkPresented = false @@ -176,7 +175,6 @@ struct LinkListDetailContainerView: View { SentryMetricsHelper.trackSearchPerformed(searchContext: "links", resultCount: resultCount) SentryMetricsHelper.trackSearchQueryLength(length: newValue.count, searchContext: "links") } - previousSearchText = oldValue } } diff --git a/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift b/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift index 3de36a3..2331ff9 100644 --- a/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift +++ b/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift @@ -28,7 +28,6 @@ struct LinkListsContainerView: View { @State private var presentedInfoList: LinkListModel? @State private var searchText = "" - @State private var previousSearchText = "" @State private var listToDelete: LinkListModel? @State private var isDeleteListPresented = false @@ -208,7 +207,6 @@ struct LinkListsContainerView: View { SentryMetricsHelper.trackSearchPerformed(searchContext: "lists", resultCount: resultCount) SentryMetricsHelper.trackSearchQueryLength(length: newValue.count, searchContext: "lists") } - previousSearchText = oldValue } }