@crumbsdk/react-native 0.0.1-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CrumbReactNative.podspec +38 -0
  2. package/LICENSE +201 -0
  3. package/README.md +109 -0
  4. package/android/CMakeLists.txt +20 -0
  5. package/android/build.gradle +118 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +10 -0
  8. package/android/src/main/java/com/margelo/nitro/crumbsdk/reactnative/CrumbReactNative.kt +228 -0
  9. package/android/src/main/java/com/margelo/nitro/crumbsdk/reactnative/ReactNativePackage.kt +22 -0
  10. package/ios/CrumbReactNative.swift +283 -0
  11. package/lib/module/CrumbReactNative.nitro.js +4 -0
  12. package/lib/module/CrumbReactNative.nitro.js.map +1 -0
  13. package/lib/module/NativeCrumbReactNative.js +5 -0
  14. package/lib/module/NativeCrumbReactNative.js.map +1 -0
  15. package/lib/module/NativeCrumbReactNative.web.js +18 -0
  16. package/lib/module/NativeCrumbReactNative.web.js.map +1 -0
  17. package/lib/module/index.js +60 -0
  18. package/lib/module/index.js.map +1 -0
  19. package/lib/module/log-buffer.js +200 -0
  20. package/lib/module/log-buffer.js.map +1 -0
  21. package/lib/module/package.json +1 -0
  22. package/lib/module/types.js +2 -0
  23. package/lib/module/types.js.map +1 -0
  24. package/lib/typescript/package.json +1 -0
  25. package/lib/typescript/src/CrumbReactNative.nitro.d.ts +12 -0
  26. package/lib/typescript/src/CrumbReactNative.nitro.d.ts.map +1 -0
  27. package/lib/typescript/src/NativeCrumbReactNative.d.ts +4 -0
  28. package/lib/typescript/src/NativeCrumbReactNative.d.ts.map +1 -0
  29. package/lib/typescript/src/NativeCrumbReactNative.web.d.ts +4 -0
  30. package/lib/typescript/src/NativeCrumbReactNative.web.d.ts.map +1 -0
  31. package/lib/typescript/src/index.d.ts +19 -0
  32. package/lib/typescript/src/index.d.ts.map +1 -0
  33. package/lib/typescript/src/log-buffer.d.ts +8 -0
  34. package/lib/typescript/src/log-buffer.d.ts.map +1 -0
  35. package/lib/typescript/src/types.d.ts +55 -0
  36. package/lib/typescript/src/types.d.ts.map +1 -0
  37. package/nitro.json +24 -0
  38. package/nitrogen/generated/.gitattributes +1 -0
  39. package/nitrogen/generated/android/c++/JHybridCrumbReactNativeSpec.cpp +94 -0
  40. package/nitrogen/generated/android/c++/JHybridCrumbReactNativeSpec.hpp +67 -0
  41. package/nitrogen/generated/android/crumbsdk_reactnative+autolinking.cmake +81 -0
  42. package/nitrogen/generated/android/crumbsdk_reactnative+autolinking.gradle +27 -0
  43. package/nitrogen/generated/android/crumbsdk_reactnativeOnLoad.cpp +54 -0
  44. package/nitrogen/generated/android/crumbsdk_reactnativeOnLoad.hpp +34 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/crumbsdk/reactnative/HybridCrumbReactNativeSpec.kt +73 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/crumbsdk/reactnative/crumbsdk_reactnativeOnLoad.kt +35 -0
  47. package/nitrogen/generated/ios/CrumbReactNative+autolinking.rb +62 -0
  48. package/nitrogen/generated/ios/CrumbReactNative-Swift-Cxx-Bridge.cpp +49 -0
  49. package/nitrogen/generated/ios/CrumbReactNative-Swift-Cxx-Bridge.hpp +119 -0
  50. package/nitrogen/generated/ios/CrumbReactNative-Swift-Cxx-Umbrella.hpp +45 -0
  51. package/nitrogen/generated/ios/CrumbReactNativeAutolinking.mm +33 -0
  52. package/nitrogen/generated/ios/CrumbReactNativeAutolinking.swift +26 -0
  53. package/nitrogen/generated/ios/c++/HybridCrumbReactNativeSpecSwift.cpp +11 -0
  54. package/nitrogen/generated/ios/c++/HybridCrumbReactNativeSpecSwift.hpp +109 -0
  55. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  56. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  57. package/nitrogen/generated/ios/swift/HybridCrumbReactNativeSpec.swift +59 -0
  58. package/nitrogen/generated/ios/swift/HybridCrumbReactNativeSpec_cxx.swift +197 -0
  59. package/nitrogen/generated/shared/c++/HybridCrumbReactNativeSpec.cpp +25 -0
  60. package/nitrogen/generated/shared/c++/HybridCrumbReactNativeSpec.hpp +67 -0
  61. package/package.json +138 -0
  62. package/src/CrumbReactNative.nitro.ts +12 -0
  63. package/src/NativeCrumbReactNative.ts +6 -0
  64. package/src/NativeCrumbReactNative.web.ts +20 -0
  65. package/src/index.ts +115 -0
  66. package/src/log-buffer.ts +265 -0
  67. package/src/types.ts +70 -0
@@ -0,0 +1,12 @@
1
+ import type { HybridObject } from 'react-native-nitro-modules';
2
+
3
+ export interface CrumbReactNative extends HybridObject<{
4
+ ios: 'swift';
5
+ android: 'kotlin';
6
+ }> {
7
+ start(configurationJson: string): void;
8
+ installReporter(): Promise<boolean>;
9
+ show(): Promise<boolean>;
10
+ addLog(entryJson: string): void;
11
+ clearLogs(): void;
12
+ }
@@ -0,0 +1,6 @@
1
+ import { NitroModules } from 'react-native-nitro-modules';
2
+ import type { CrumbReactNative } from './CrumbReactNative.nitro';
3
+
4
+ export default NitroModules.createHybridObject<CrumbReactNative>(
5
+ 'CrumbReactNative'
6
+ );
@@ -0,0 +1,20 @@
1
+ import type { CrumbReactNative } from './CrumbReactNative.nitro';
2
+
3
+ const unsupportedMessage =
4
+ 'Crumb requires an iOS or Android native build and is unavailable on web or in Expo Go.';
5
+
6
+ const unsupportedModule: CrumbReactNative = {
7
+ name: 'CrumbReactNative',
8
+ dispose: () => undefined,
9
+ equals: () => false,
10
+ toString: () => '[HybridObject CrumbReactNative unavailable on web]',
11
+ start: () => {
12
+ throw new Error(unsupportedMessage);
13
+ },
14
+ installReporter: () => Promise.resolve(false),
15
+ show: () => Promise.resolve(false),
16
+ addLog: () => undefined,
17
+ clearLogs: () => undefined,
18
+ };
19
+
20
+ export default unsupportedModule;
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ import NativeCrumbReactNative from './NativeCrumbReactNative';
2
+ import {
3
+ clearLogs,
4
+ configureLogBuffer,
5
+ disableConsoleCapture,
6
+ enableConsoleCapture,
7
+ markNativeStarted,
8
+ writeLog,
9
+ } from './log-buffer';
10
+ import type {
11
+ CrumbConfiguration,
12
+ CrumbLogLevel,
13
+ CrumbLogMetadata,
14
+ } from './types';
15
+
16
+ export type {
17
+ CrumbCaptureOptions,
18
+ CrumbConfiguration,
19
+ CrumbDiagnosticsOptions,
20
+ CrumbInvocation,
21
+ CrumbLogLevel,
22
+ CrumbLogMetadata,
23
+ CrumbLogOptions,
24
+ CrumbPrivacyOptions,
25
+ CrumbRelease,
26
+ CrumbUploadOptions,
27
+ } from './types';
28
+
29
+ export async function start(configuration: CrumbConfiguration): Promise<void> {
30
+ validateConfiguration(configuration);
31
+ const logOptions = configuration.diagnostics?.logs;
32
+ configureLogBuffer(logOptions);
33
+ NativeCrumbReactNative.start(JSON.stringify(configuration));
34
+ markNativeStarted();
35
+
36
+ if (logOptions?.captureConsole) {
37
+ enableConsoleCapture();
38
+ } else {
39
+ disableConsoleCapture();
40
+ }
41
+ }
42
+
43
+ export function installReporter(): Promise<boolean> {
44
+ return NativeCrumbReactNative.installReporter();
45
+ }
46
+
47
+ export function show(): Promise<boolean> {
48
+ return NativeCrumbReactNative.show();
49
+ }
50
+
51
+ export function log(
52
+ level: CrumbLogLevel,
53
+ message: string,
54
+ metadata?: CrumbLogMetadata
55
+ ): void {
56
+ if (!message.trim()) {
57
+ throw new TypeError('Crumb.log message must not be empty.');
58
+ }
59
+ writeLog(level, message, metadata);
60
+ }
61
+
62
+ export { clearLogs, disableConsoleCapture, enableConsoleCapture };
63
+
64
+ function validateConfiguration(configuration: CrumbConfiguration): void {
65
+ if (!configuration.projectKey?.trim()) {
66
+ throw new TypeError('Crumb projectKey must not be empty.');
67
+ }
68
+ if (!configuration.environment?.trim()) {
69
+ throw new TypeError('Crumb environment must not be empty.');
70
+ }
71
+
72
+ assertPositiveInteger(
73
+ configuration.capture?.maximumScreenshotDimension,
74
+ 'capture.maximumScreenshotDimension'
75
+ );
76
+ assertPositiveInteger(
77
+ configuration.capture?.maximumScreenshotBytes,
78
+ 'capture.maximumScreenshotBytes'
79
+ );
80
+ assertPositiveInteger(
81
+ configuration.diagnostics?.timeoutMs,
82
+ 'diagnostics.timeoutMs'
83
+ );
84
+ assertPositiveInteger(
85
+ configuration.diagnostics?.logs?.lookbackMs,
86
+ 'diagnostics.logs.lookbackMs'
87
+ );
88
+ assertPositiveInteger(
89
+ configuration.diagnostics?.logs?.maximumEntries,
90
+ 'diagnostics.logs.maximumEntries'
91
+ );
92
+ assertPositiveInteger(
93
+ configuration.diagnostics?.logs?.maximumBytes,
94
+ 'diagnostics.logs.maximumBytes'
95
+ );
96
+ }
97
+
98
+ function assertPositiveInteger(value: number | undefined, field: string): void {
99
+ if (value === undefined) return;
100
+ if (!Number.isSafeInteger(value) || value <= 0) {
101
+ throw new TypeError(`Crumb ${field} must be a positive integer.`);
102
+ }
103
+ }
104
+
105
+ const Crumb = Object.freeze({
106
+ start,
107
+ installReporter,
108
+ show,
109
+ log,
110
+ clearLogs,
111
+ enableConsoleCapture,
112
+ disableConsoleCapture,
113
+ });
114
+
115
+ export default Crumb;
@@ -0,0 +1,265 @@
1
+ import NativeCrumbReactNative from './NativeCrumbReactNative';
2
+ import type {
3
+ CrumbLogEntry,
4
+ CrumbLogLevel,
5
+ CrumbLogMetadata,
6
+ CrumbLogOptions,
7
+ } from './types';
8
+
9
+ const DEFAULT_LOOKBACK_MS = 60_000;
10
+ const DEFAULT_MAXIMUM_ENTRIES = 200;
11
+ const DEFAULT_MAXIMUM_BYTES = 65_536;
12
+ const MAXIMUM_METADATA_DEPTH = 4;
13
+ const MAXIMUM_OBJECT_KEYS = 24;
14
+ const MAXIMUM_ARRAY_ITEMS = 24;
15
+ const MAXIMUM_STRING_LENGTH = 2_048;
16
+ const MAXIMUM_ENTRY_BYTES = 16_384;
17
+
18
+ interface BufferLimits {
19
+ lookbackMs: number;
20
+ maximumEntries: number;
21
+ maximumBytes: number;
22
+ }
23
+
24
+ type ConsoleMethod = (...data: unknown[]) => void;
25
+
26
+ let limits: BufferLimits = {
27
+ lookbackMs: DEFAULT_LOOKBACK_MS,
28
+ maximumEntries: DEFAULT_MAXIMUM_ENTRIES,
29
+ maximumBytes: DEFAULT_MAXIMUM_BYTES,
30
+ };
31
+ let entries: CrumbLogEntry[] = [];
32
+ let usedBytes = 0;
33
+ let nativeStarted = false;
34
+ let originalWarn: ConsoleMethod | undefined;
35
+ let originalError: ConsoleMethod | undefined;
36
+ let capturedWarn: ConsoleMethod | undefined;
37
+ let capturedError: ConsoleMethod | undefined;
38
+
39
+ export function configureLogBuffer(options: CrumbLogOptions | undefined): void {
40
+ limits = {
41
+ lookbackMs: options?.lookbackMs ?? DEFAULT_LOOKBACK_MS,
42
+ maximumEntries: options?.maximumEntries ?? DEFAULT_MAXIMUM_ENTRIES,
43
+ maximumBytes: options?.maximumBytes ?? DEFAULT_MAXIMUM_BYTES,
44
+ };
45
+ prune(Date.now());
46
+ }
47
+
48
+ export function markNativeStarted(): void {
49
+ nativeStarted = true;
50
+ NativeCrumbReactNative.clearLogs();
51
+ for (const entry of entries) {
52
+ NativeCrumbReactNative.addLog(JSON.stringify(entry));
53
+ }
54
+ }
55
+
56
+ export function clearLogs(): void {
57
+ entries = [];
58
+ usedBytes = 0;
59
+ if (nativeStarted) {
60
+ NativeCrumbReactNative.clearLogs();
61
+ }
62
+ }
63
+
64
+ export function writeLog(
65
+ level: CrumbLogLevel,
66
+ message: string,
67
+ metadata?: CrumbLogMetadata,
68
+ category: CrumbLogEntry['category'] = 'javascript'
69
+ ): void {
70
+ const timestampMs = Date.now();
71
+ const metadataText = metadata ? serializeMetadata(metadata) : undefined;
72
+ const entry: CrumbLogEntry = {
73
+ timestampMs,
74
+ level,
75
+ source: 'react-native',
76
+ category,
77
+ message: truncateUtf8(
78
+ metadataText ? `${message}\nmetadata=${metadataText}` : message,
79
+ MAXIMUM_ENTRY_BYTES
80
+ ),
81
+ };
82
+
83
+ const bytes = byteLength(JSON.stringify(entry));
84
+ entries.push(entry);
85
+ usedBytes += bytes;
86
+ prune(timestampMs);
87
+
88
+ if (nativeStarted && entries.includes(entry)) {
89
+ NativeCrumbReactNative.addLog(JSON.stringify(entry));
90
+ }
91
+ }
92
+
93
+ export function enableConsoleCapture(): void {
94
+ if (capturedWarn || capturedError) return;
95
+
96
+ originalWarn = console.warn;
97
+ originalError = console.error;
98
+ capturedWarn = (...data: unknown[]) => {
99
+ originalWarn?.apply(console, data);
100
+ writeConsoleLog('warning', data);
101
+ };
102
+ capturedError = (...data: unknown[]) => {
103
+ originalError?.apply(console, data);
104
+ writeConsoleLog('error', data);
105
+ };
106
+ console.warn = capturedWarn;
107
+ console.error = capturedError;
108
+ }
109
+
110
+ export function disableConsoleCapture(): void {
111
+ if (capturedWarn && console.warn === capturedWarn && originalWarn) {
112
+ console.warn = originalWarn;
113
+ }
114
+ if (capturedError && console.error === capturedError && originalError) {
115
+ console.error = originalError;
116
+ }
117
+ originalWarn = undefined;
118
+ originalError = undefined;
119
+ capturedWarn = undefined;
120
+ capturedError = undefined;
121
+ }
122
+
123
+ function writeConsoleLog(level: 'warning' | 'error', data: unknown[]): void {
124
+ const suppliedError = data.find(
125
+ (value): value is Error => value instanceof Error
126
+ );
127
+ const rendered = data.map(renderConsoleValue).join(' ');
128
+ const stack = suppliedError?.stack ?? new Error(`console.${level}`).stack;
129
+ const message = stack ? `${rendered}\n${stack}` : rendered;
130
+ writeLog(level, message, undefined, 'console');
131
+ }
132
+
133
+ function renderConsoleValue(value: unknown): string {
134
+ if (value instanceof Error) return value.stack ?? value.message;
135
+ if (typeof value === 'string') return value;
136
+ if (
137
+ typeof value === 'number' ||
138
+ typeof value === 'boolean' ||
139
+ typeof value === 'bigint'
140
+ ) {
141
+ return String(value);
142
+ }
143
+ if (value === null) return 'null';
144
+ if (value === undefined) return 'undefined';
145
+ return serializeMetadata({ value });
146
+ }
147
+
148
+ function serializeMetadata(metadata: CrumbLogMetadata): string {
149
+ const seen = new WeakSet<object>();
150
+ const bounded = boundValue(metadata, 0, seen);
151
+ try {
152
+ return truncateUtf8(JSON.stringify(bounded), MAXIMUM_ENTRY_BYTES / 2);
153
+ } catch {
154
+ return '"[Unserializable metadata]"';
155
+ }
156
+ }
157
+
158
+ function boundValue(
159
+ value: unknown,
160
+ depth: number,
161
+ seen: WeakSet<object>
162
+ ): unknown {
163
+ if (value instanceof Error) {
164
+ return {
165
+ name: value.name,
166
+ message: truncateString(value.message),
167
+ stack: value.stack ? truncateString(value.stack) : undefined,
168
+ };
169
+ }
170
+ if (typeof value === 'string') return truncateString(value);
171
+ if (
172
+ value === null ||
173
+ typeof value === 'number' ||
174
+ typeof value === 'boolean'
175
+ ) {
176
+ return value;
177
+ }
178
+ if (typeof value === 'bigint') return String(value);
179
+ if (typeof value === 'undefined') return '[Undefined]';
180
+ if (typeof value === 'function') return '[Function]';
181
+ if (typeof value === 'symbol') return String(value);
182
+ if (depth >= MAXIMUM_METADATA_DEPTH) return '[Max depth]';
183
+ if (typeof value !== 'object') return String(value);
184
+ if (seen.has(value)) return '[Circular]';
185
+
186
+ seen.add(value);
187
+ if (Array.isArray(value)) {
188
+ const result = value
189
+ .slice(0, MAXIMUM_ARRAY_ITEMS)
190
+ .map((item) => boundValue(item, depth + 1, seen));
191
+ seen.delete(value);
192
+ return result;
193
+ }
194
+
195
+ const result: Record<string, unknown> = {};
196
+ for (const key of Object.keys(value).slice(0, MAXIMUM_OBJECT_KEYS)) {
197
+ result[truncateString(key)] = boundValue(
198
+ (value as Record<string, unknown>)[key],
199
+ depth + 1,
200
+ seen
201
+ );
202
+ }
203
+ seen.delete(value);
204
+ return result;
205
+ }
206
+
207
+ function prune(now: number): void {
208
+ const earliest = now - limits.lookbackMs;
209
+ while (entries[0] && entries[0].timestampMs < earliest) {
210
+ removeOldest();
211
+ }
212
+ while (
213
+ entries.length > limits.maximumEntries ||
214
+ usedBytes > limits.maximumBytes
215
+ ) {
216
+ removeOldest();
217
+ }
218
+ }
219
+
220
+ function removeOldest(): void {
221
+ const removed = entries.shift();
222
+ if (!removed) return;
223
+ usedBytes -= byteLength(JSON.stringify(removed));
224
+ }
225
+
226
+ function truncateString(value: string): string {
227
+ return value.length <= MAXIMUM_STRING_LENGTH
228
+ ? value
229
+ : `${value.slice(0, MAXIMUM_STRING_LENGTH)}…`;
230
+ }
231
+
232
+ function truncateUtf8(value: string, maximumBytes: number): string {
233
+ if (byteLength(value) <= maximumBytes) return value;
234
+
235
+ let low = 0;
236
+ let high = value.length;
237
+ while (low < high) {
238
+ const middle = Math.ceil((low + high) / 2);
239
+ if (byteLength(value.slice(0, middle)) <= maximumBytes - 3) {
240
+ low = middle;
241
+ } else {
242
+ high = middle - 1;
243
+ }
244
+ }
245
+ return `${value.slice(0, low)}…`;
246
+ }
247
+
248
+ function byteLength(value: string): number {
249
+ let bytes = 0;
250
+ for (let index = 0; index < value.length; index += 1) {
251
+ const code = value.charCodeAt(index);
252
+ if (code <= 0x7f) {
253
+ bytes += 1;
254
+ } else if (code <= 0x7ff) {
255
+ bytes += 2;
256
+ } else if (code >= 0xd800 && code <= 0xdbff) {
257
+ const next = value.charCodeAt(index + 1);
258
+ if (next >= 0xdc00 && next <= 0xdfff) index += 1;
259
+ bytes += 4;
260
+ } else {
261
+ bytes += 3;
262
+ }
263
+ }
264
+ return bytes;
265
+ }
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ export type CrumbInvocation = 'shake' | 'programmatic';
2
+
3
+ export type CrumbLogLevel =
4
+ | 'debug'
5
+ | 'info'
6
+ | 'notice'
7
+ | 'warning'
8
+ | 'error'
9
+ | 'fault';
10
+
11
+ export interface CrumbRelease {
12
+ /** Defaults to the native bundle version name. */
13
+ appVersion?: string;
14
+ /** Defaults to the native bundle build number. */
15
+ nativeBuild?: string;
16
+ /** OTA or JavaScript bundle identity supplied by the host app. */
17
+ bundleVersion?: string;
18
+ }
19
+
20
+ export interface CrumbCaptureOptions {
21
+ screenshot?: boolean;
22
+ maximumScreenshotDimension?: number;
23
+ maximumScreenshotBytes?: number;
24
+ }
25
+
26
+ export interface CrumbLogOptions {
27
+ enabled?: boolean;
28
+ lookbackMs?: number;
29
+ maximumEntries?: number;
30
+ maximumBytes?: number;
31
+ /** Captures console.warn and console.error while preserving their behavior. */
32
+ captureConsole?: boolean;
33
+ }
34
+
35
+ export interface CrumbDiagnosticsOptions {
36
+ healthCheckUrl?: string;
37
+ timeoutMs?: number;
38
+ logs?: CrumbLogOptions;
39
+ }
40
+
41
+ export interface CrumbPrivacyOptions {
42
+ maskAllTextInputs?: boolean;
43
+ maskScreenshotsBeforeUpload?: boolean;
44
+ }
45
+
46
+ export interface CrumbUploadOptions {
47
+ /** Base URL of the Crumb ingestion API. Omit to keep reports local-only. */
48
+ ingestionUrl?: string;
49
+ }
50
+
51
+ export interface CrumbConfiguration {
52
+ projectKey: string;
53
+ environment: string;
54
+ release?: CrumbRelease;
55
+ invocation?: readonly CrumbInvocation[];
56
+ capture?: CrumbCaptureOptions;
57
+ diagnostics?: CrumbDiagnosticsOptions;
58
+ privacy?: CrumbPrivacyOptions;
59
+ upload?: CrumbUploadOptions;
60
+ }
61
+
62
+ export type CrumbLogMetadata = Readonly<Record<string, unknown>>;
63
+
64
+ export interface CrumbLogEntry {
65
+ timestampMs: number;
66
+ level: CrumbLogLevel;
67
+ source: 'react-native';
68
+ category: 'javascript' | 'console';
69
+ message: string;
70
+ }