diff --git a/packages/analytics/CHANGELOG.md b/packages/analytics/CHANGELOG.md new file mode 100644 index 00000000..211c12cb --- /dev/null +++ b/packages/analytics/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] + +### Added + +- Initial release with `smart_accounts_kit_initialized` analytics event and batching client. + +[Unreleased]: https://github.com/metamask/smart-accounts-kit/compare/@metamask/smart-accounts-kit-analytics@0.1.0...HEAD +[0.1.0]: https://github.com/metamask/smart-accounts-kit/releases/tag/@metamask/smart-accounts-kit-analytics@0.1.0 diff --git a/packages/analytics/LICENSE.APACHE2 b/packages/analytics/LICENSE.APACHE2 new file mode 100644 index 00000000..49966a71 --- /dev/null +++ b/packages/analytics/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 ConsenSys Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/analytics/LICENSE.MIT0 b/packages/analytics/LICENSE.MIT0 new file mode 100644 index 00000000..74e1d3df --- /dev/null +++ b/packages/analytics/LICENSE.MIT0 @@ -0,0 +1,16 @@ +MIT No Attribution + +Copyright 2022 ConsenSys Software Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/analytics/README.md b/packages/analytics/README.md new file mode 100644 index 00000000..9c66582b --- /dev/null +++ b/packages/analytics/README.md @@ -0,0 +1,13 @@ +# `@metamask/smart-accounts-kit-analytics` + +**Private workspace package** — not published to npm. It is **bundled into** `@metamask/smart-accounts-kit` and used **only from that package’s source** (internal telemetry for the kit itself). It is **not** part of the public `@metamask/smart-accounts-kit` API. + +### Session base + +Module state holds **one session** per process or browser tab: `getSessionBaseProperties()` returns the current base. `getInitializationContext` starts or updates the session (stable `anon_id`, `platform`, optional `domain`). `analytics.setGlobalProperty` / `mergeSessionProperties` update that store; `trackInitialized(overrides?)` merges session + overrides, persists the result, and sends `smart_accounts_kit_initialized`. `trackSdkFunctionCall(functionName, parameters?)` sends `smart_accounts_kit_function_called` with the same base properties plus `function_name` and optional non-sensitive `parameters` (does not update the session store). + +The package exports the default **`METAMASK_ANALYTICS_ENDPOINT`** URL, the **`Analytics`** class, and schema/session helpers from **`index.ts`**. + +## Contributing + +This package is part of the [smart-accounts-kit](https://github.com/metamask/smart-accounts-kit) monorepo. diff --git a/packages/analytics/eslint.config.mjs b/packages/analytics/eslint.config.mjs new file mode 100644 index 00000000..08878a32 --- /dev/null +++ b/packages/analytics/eslint.config.mjs @@ -0,0 +1,15 @@ +// eslint-disable-next-line +import baseConfig from '../../shared/config/base.eslint.mjs'; + +const config = [ + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/naming-convention': 'warn', + }, + }, +]; + +export default config; diff --git a/packages/analytics/package.json b/packages/analytics/package.json new file mode 100644 index 00000000..2ce85adc --- /dev/null +++ b/packages/analytics/package.json @@ -0,0 +1,73 @@ +{ + "name": "@metamask/smart-accounts-kit-analytics", + "version": "0.1.0", + "private": true, + "description": "Internal analytics client bundled with @metamask/smart-accounts-kit (not published on its own)", + "license": "(MIT-0 OR Apache-2.0)", + "type": "module", + "keywords": [ + "MetaMask", + "Ethereum", + "Smart Accounts" + ], + "contributors": [], + "homepage": "https://github.com/metamask/smart-accounts-kit/tree/main/packages/analytics#readme", + "bugs": { + "url": "https://github.com/metamask/smart-accounts-kit/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/metamask/smart-accounts-kit.git" + }, + "author": "MetaMask ", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**", + "dist/" + ], + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + } + }, + "./package.json": "./package.json" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "scripts": { + "build": "yarn typecheck && tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "changelog:update": "../../scripts/update-changelog.sh @metamask/smart-accounts-kit-analytics", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/smart-accounts-kit-analytics", + "clean": "rm -rf dist", + "lint": "yarn lint:eslint", + "lint:eslint": "eslint . --cache --ext js,ts", + "lint:fix": "yarn lint:eslint --fix" + }, + "peerDependencies": { + "openapi-fetch": "^0.13.5" + }, + "devDependencies": { + "@metamask/auto-changelog": "^5.0.2", + "@types/node": "^20.19.0", + "eslint": "^9.39.2", + "nock": "^14.0.4", + "openapi-fetch": "^0.13.5", + "prettier": "^3.5.3", + "tsup": "^8.5.0", + "typescript": "5.5.4", + "vitest": "^3.2.4" + } +} diff --git a/packages/analytics/src/analytics.test.ts b/packages/analytics/src/analytics.test.ts new file mode 100644 index 00000000..5f470683 --- /dev/null +++ b/packages/analytics/src/analytics.test.ts @@ -0,0 +1,218 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import nock from 'nock'; +import { describe, beforeEach, afterAll, it, expect } from 'vitest'; + +import { Analytics } from '.'; +import { + getInitializationContext, + resetAnalyticsSessionForTests, +} from './environment'; +import type { AnalyticsEventV2 } from './schema'; + +describe('Analytics', () => { + let analytics: Analytics; + + beforeEach(() => { + resetAnalyticsSessionForTests(); + }); + + afterAll(() => { + /* eslint-disable-next-line import-x/no-named-as-default-member */ + nock.cleanAll(); + }); + + it('should do nothing when disabled', async () => { + let captured: AnalyticsEventV2[] = []; + const scope = nock('http://127.0.0.1') + .post('/v2/events', (body) => { + captured = body; + return true; + }) + .optionally() + .reply( + 200, + { status: 'success' }, + { 'Content-Type': 'application/json' }, + ); + + getInitializationContext({ + sdk_version: '0.0.0-test', + anon_id: '00000000-0000-4000-8000-000000000001', + }); + analytics = new Analytics('http://127.0.0.1'); + analytics.trackInitialized(); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(captured).toEqual([]); + + scope.done(); + }); + + it('should track initialization when enabled', async () => { + let captured: AnalyticsEventV2[] = []; + const scope = nock('http://127.0.0.2') + .post('/v2/events', (body) => { + captured = body; + return true; + }) + .reply( + 200, + { status: 'success' }, + { 'Content-Type': 'application/json' }, + ); + + getInitializationContext({ + sdk_version: '1.2.3', + anon_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + }); + analytics = new Analytics('http://127.0.0.2'); + analytics.enable(); + analytics.trackInitialized({ + platform: 'web-desktop', + domain: 'example.com', + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(captured).toEqual([ + { + namespace: 'metamask/smart-accounts-kit', + event_name: 'smart_accounts_kit_initialized', + properties: { + sdk_version: '1.2.3', + anon_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + platform: 'web-desktop', + domain: 'example.com', + }, + }, + ]); + + scope.done(); + }); + + it('should track using session base only when trackInitialized has no args', async () => { + let captured: AnalyticsEventV2[] = []; + const scope = nock('http://127.0.0.3') + .post('/v2/events', (body) => { + captured = body; + return true; + }) + .reply( + 200, + { status: 'success' }, + { 'Content-Type': 'application/json' }, + ); + + getInitializationContext({ + sdk_version: '2.0.0', + anon_id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + }); + analytics = new Analytics('http://127.0.0.3'); + analytics.enable(); + analytics.trackInitialized(); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(captured).toEqual([ + { + namespace: 'metamask/smart-accounts-kit', + event_name: 'smart_accounts_kit_initialized', + properties: { + sdk_version: '2.0.0', + anon_id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + platform: 'nodejs', + }, + }, + ]); + + scope.done(); + }); + + it('merges setGlobalProperty into session before trackInitialized', async () => { + let captured: AnalyticsEventV2[] = []; + const scope = nock('http://127.0.0.4') + .post('/v2/events', (body) => { + captured = body; + return true; + }) + .reply( + 200, + { status: 'success' }, + { 'Content-Type': 'application/json' }, + ); + + getInitializationContext({ + sdk_version: '1.0.0', + anon_id: 'cccccccc-cccc-cccc-cccc-cccccccccccc', + }); + analytics = new Analytics('http://127.0.0.4'); + analytics.enable(); + analytics.setGlobalProperty('sdk_version', '3.0.0'); + analytics.trackInitialized(); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(captured[0]?.properties?.sdk_version).toBe('3.0.0'); + + scope.done(); + }); + + it('throws when session was never started', () => { + resetAnalyticsSessionForTests(); + analytics = new Analytics('http://127.0.0.5'); + analytics.enable(); + expect(() => analytics.trackInitialized()).toThrow( + /getInitializationContext/iu, + ); + }); + + it('should track SDK function call when enabled', async () => { + let captured: AnalyticsEventV2[] = []; + const scope = nock('http://127.0.0.6') + .post('/v2/events', (body) => { + captured = body; + return true; + }) + .reply( + 200, + { status: 'success' }, + { 'Content-Type': 'application/json' }, + ); + + getInitializationContext({ + sdk_version: '1.0.0', + anon_id: 'dddddddd-dddd-dddd-dddd-dddddddddddd', + }); + analytics = new Analytics('http://127.0.0.6'); + analytics.enable(); + analytics.trackSdkFunctionCall('testFn', { foo: 'bar' }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(captured).toEqual([ + { + namespace: 'metamask/smart-accounts-kit', + event_name: 'smart_accounts_kit_function_called', + properties: { + sdk_version: '1.0.0', + anon_id: 'dddddddd-dddd-dddd-dddd-dddddddddddd', + platform: 'nodejs', + function_name: 'testFn', + parameters: { foo: 'bar' }, + }, + }, + ]); + + scope.done(); + }); + + it('throws when trackSdkFunctionCall session was never started', () => { + resetAnalyticsSessionForTests(); + analytics = new Analytics('http://127.0.0.7'); + analytics.enable(); + expect(() => analytics.trackSdkFunctionCall('x')).toThrow( + /getInitializationContext/iu, + ); + }); +}); diff --git a/packages/analytics/src/environment.test.ts b/packages/analytics/src/environment.test.ts new file mode 100644 index 00000000..18ea744a --- /dev/null +++ b/packages/analytics/src/environment.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/naming-convention -- analytics payload field names */ +import { describe, beforeEach, it, expect } from 'vitest'; + +import { + getInitializationContext, + getSessionBaseProperties, + resetAnalyticsSessionForTests, +} from './environment'; + +describe('analytics session (environment)', () => { + beforeEach(() => { + resetAnalyticsSessionForTests(); + }); + + it('returns nodejs platform with required fields when window is not present', () => { + const ctx = getInitializationContext({ + sdk_version: '9.9.9', + anon_id: '11111111-2222-4333-8444-555555555555', + }); + expect(ctx).toEqual({ + sdk_version: '9.9.9', + anon_id: '11111111-2222-4333-8444-555555555555', + platform: 'nodejs', + }); + }); + + it('generates anon_id when omitted on first call only', () => { + const first = getInitializationContext({ sdk_version: '1.0.0' }); + expect(first.sdk_version).toBe('1.0.0'); + expect(first.platform).toBe('nodejs'); + expect(first.anon_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu, + ); + + const second = getInitializationContext({ sdk_version: '2.0.0' }); + expect(second.anon_id).toBe(first.anon_id); + expect(second.sdk_version).toBe('2.0.0'); + }); + + it('getSessionBaseProperties throws before session start', () => { + expect(() => getSessionBaseProperties()).toThrow( + /getInitializationContext/iu, + ); + }); + + it('getSessionBaseProperties returns copy after init', () => { + getInitializationContext({ sdk_version: '0.1.0' }); + const a = getSessionBaseProperties(); + const b = getSessionBaseProperties(); + expect(a).toEqual(b); + expect(a).not.toBe(b); + }); +}); diff --git a/packages/analytics/src/environment.ts b/packages/analytics/src/environment.ts new file mode 100644 index 00000000..204aefb6 --- /dev/null +++ b/packages/analytics/src/environment.ts @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/naming-convention -- analytics payload field names */ +import type { SmartAccountsKitBaseProperties } from './schema'; + +export type GetInitializationContextParams = { + /** + * Smart Accounts Kit (or embedding SDK) version, e.g. from the host `package.json`. + */ + sdk_version: string; + /** + * Anonymous session identifier for this SDK instance / page lifecycle. + * If omitted on the **first** call, a UUID is generated and reused for the whole session. + * Ignored on later calls (first `anon_id` wins). + */ + anon_id?: string; +}; + +/** One analytics session per JS runtime / page load / Node process import lifecycle. */ +let session: SmartAccountsKitBaseProperties | undefined; + +/** + * @returns A UUID when `crypto.randomUUID` exists; otherwise a UUID-shaped fallback string. + */ +function createAnonId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `00000000-0000-4000-8000-${Math.random() + .toString(16) + .slice(2, 14) + .padEnd(12, '0')}`; +} + +/** + * @returns Connect-style `platform` for the current JS runtime (browser vs Node). + */ +function inferPlatform(): SmartAccountsKitBaseProperties['platform'] { + if (typeof globalThis === 'undefined' || !('window' in globalThis)) { + return 'nodejs'; + } + const nav = (globalThis as Window & typeof globalThis).navigator; + const ua = typeof nav?.userAgent === 'string' ? nav.userAgent : ''; + if ( + /Mobile|Android|iPhone|iPod|webOS|BlackBerry|IEMobile|Opera Mini/iu.test(ua) + ) { + return 'web-mobile'; + } + return 'web-desktop'; +} + +/** + * @returns `{ domain: hostname }` in a browser when `location.hostname` is set; otherwise `{}`. + */ +function inferDomain(): Pick { + if (typeof globalThis !== 'undefined' && 'window' in globalThis) { + const win = globalThis as Window & typeof globalThis; + const hostname = win.location?.hostname; + if (typeof hostname === 'string' && hostname.length > 0) { + return { domain: hostname }; + } + } + return {}; +} + +/** + * Starts or updates the SDK analytics session and returns the current base properties snapshot. + * First call allocates a stable `anon_id`, infers `platform` and optional `domain`, and stores `sdk_version`. + * Later calls reuse `anon_id`, `platform`, and `domain`; `sdk_version` is updated from params. + * + * @param params - At minimum `sdk_version` each time you call (typically your kit version). + * @returns A copy of the session base; use {@link getSessionBaseProperties} for subsequent reads. + */ +export function getInitializationContext( + params: GetInitializationContextParams, +): SmartAccountsKitBaseProperties { + if (!session) { + session = { + sdk_version: params.sdk_version, + anon_id: params.anon_id ?? createAnonId(), + platform: inferPlatform(), + ...inferDomain(), + }; + return { ...session }; + } + + session = { + ...session, + sdk_version: params.sdk_version, + }; + return { ...session }; +} + +/** + * Current session base (`sdk_version`, stable `anon_id`, `platform`, optional `domain`). + * Call {@link getInitializationContext} once at startup before recording events. + * + * @returns A shallow copy of the stored base properties. + */ +export function getSessionBaseProperties(): SmartAccountsKitBaseProperties { + if (!session) { + throw new Error( + 'Smart Accounts Kit analytics: call getInitializationContext({ sdk_version }) at SDK startup before recording events.', + ); + } + return { ...session }; +} + +/** + * Merges fields into the session base (e.g. {@link import('./analytics').default.setGlobalProperty}). + * + * @param partial - Properties to merge over the current session. + */ +export function mergeSessionProperties( + partial: Partial, +): void { + if (!session) { + throw new Error( + 'Smart Accounts Kit analytics: call getInitializationContext before mergeSessionProperties.', + ); + } + session = { ...session, ...partial }; +} + +/** + * @returns Whether a session has been started with {@link getInitializationContext}. + */ +export function isAnalyticsSessionStarted(): boolean { + return session !== undefined; +} + +/** + * Clears the session (for tests only). + */ +export function resetAnalyticsSessionForTests(): void { + session = undefined; +} diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts new file mode 100644 index 00000000..b987bc05 --- /dev/null +++ b/packages/analytics/src/index.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-restricted-syntax */ +import createClient from 'openapi-fetch'; + +import { + getSessionBaseProperties, + mergeSessionProperties, +} from './environment'; +import type { + AnalyticsEventV2, + SmartAccountsKitFunctionCallParameters, + SmartAccountsKitFunctionCallPayload, + SmartAccountsKitFunctionCallProperties, + SmartAccountsKitBaseProperties, + paths, +} from './schema'; +import Sender from './sender'; + +/** + * @param value - Candidate merged base properties. + * @returns Whether `sdk_version`, `anon_id`, and `platform` are all non-empty strings. + */ +function isCompleteBase( + value: Partial, +): value is SmartAccountsKitBaseProperties { + return ( + typeof value.sdk_version === 'string' && + value.sdk_version.length > 0 && + typeof value.anon_id === 'string' && + value.anon_id.length > 0 && + typeof value.platform === 'string' && + value.platform.length > 0 + ); +} + +/** + * Deep-clones analytics payloads, stringifying bigint values like `42n` to `"42n"`, + * so they can be JSON-serialized. + * + * @param batch - Batch of analytics events to normalise. + * @returns Normalised batch of analytics events. + */ +function normalise(batch: AnalyticsEventV2[]): AnalyticsEventV2[] { + const walk = (value: unknown): unknown => { + if (typeof value === 'bigint') { + return `${value}n`; + } + if (value === null || typeof value !== 'object') { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => walk(item)); + } + const result: Record = {}; + for (const [key, child] of Object.entries( + value as Record, + )) { + result[key] = walk(child); + } + return result; + }; + + return walk(batch) as AnalyticsEventV2[]; +} + +export class Analytics { + private enabled = false; + + private readonly sender: Sender; + + constructor(baseUrl: string) { + const client = createClient({ baseUrl }); + + const sendFn = async (batch: AnalyticsEventV2[]): Promise => { + const normalisedBatch = normalise(batch); + + const res = await client.POST('/v2/events', { body: normalisedBatch }); + if (res.response.status !== 200) { + throw new Error(String(res.error)); + } + }; + + // timeout maximums are kept low, to avoid failing analytics requests keeping the process alive for too long + this.sender = new Sender({ + batchSize: 100, + baseTimeoutMs: 100, + maxFailureCount: 3, + maxTimeoutMs: 500, + sendFn, + }); + } + + public enable(): void { + this.enabled = true; + } + + /** + * Merges a field into the session base (shared with {@link getSessionBaseProperties}). + * + * @param key - Base property name. + * @param value - Value for that property. + */ + public setGlobalProperty( + key: K, + value: SmartAccountsKitBaseProperties[K], + ): void { + mergeSessionProperties({ + [key]: value, + } as Partial); + } + + /** + * Sends `smart_accounts_kit_initialized` using the session base from {@link getInitializationContext} + * plus optional per-event overrides. Updates the stored session with the merged snapshot. + * + * @param properties - Optional overrides; omit to use the current session base only. + */ + public trackInitialized( + properties: Partial = {}, + ): void { + if (!this.enabled) { + return; + } + + const merged: Partial = { + ...getSessionBaseProperties(), + ...properties, + }; + + if (!isCompleteBase(merged)) { + throw new Error( + 'Analytics: trackInitialized produced incomplete base configuration (ensure getInitializationContext ran and sdk_version, anon_id, platform are set)', + ); + } + + mergeSessionProperties(merged); + + const event: AnalyticsEventV2 = { + namespace: 'metamask/smart-accounts-kit', + event_name: 'smart_accounts_kit_initialized', + properties: merged, + }; + + this.sender.enqueue(event); + } + + /** + * Sends `smart_accounts_kit_function_called` with session base plus the function name and optional + * non-sensitive parameters. Does not mutate the session store (unlike {@link trackInitialized}). + * + * @param functionName - Public SDK entry name (use a stable string, e.g. `createDelegation`). + * @param parameters - Safe primitives only; omit secrets, keys, and raw addresses if sensitive. + * @param baseOverrides - Optional overrides for base fields (same as {@link trackInitialized}). + */ + public trackSdkFunctionCall( + functionName: string, + parameters?: SmartAccountsKitFunctionCallParameters, + baseOverrides: Partial = {}, + ): void { + if (!this.enabled) { + return; + } + + const mergedBase: Partial = { + ...getSessionBaseProperties(), + ...baseOverrides, + }; + + if (!isCompleteBase(mergedBase)) { + throw new Error( + 'Analytics: trackSdkFunctionCall requires session (call getInitializationContext before tracking)', + ); + } + + const props: SmartAccountsKitFunctionCallProperties = { + ...mergedBase, + function_name: functionName, + ...(parameters !== undefined && Object.keys(parameters).length > 0 + ? { parameters } + : {}), + }; + + const event: SmartAccountsKitFunctionCallPayload = { + namespace: 'metamask/smart-accounts-kit', + event_name: 'smart_accounts_kit_function_called', + properties: props, + }; + + this.sender.enqueue(event); + } +} + +/** Default MetaMask SDK analytics API base URL. */ +export const METAMASK_ANALYTICS_ENDPOINT = + 'https://mm-sdk-analytics.api.cx.metamask.io/'; + +export { + getInitializationContext, + getSessionBaseProperties, + isAnalyticsSessionStarted, + mergeSessionProperties, + resetAnalyticsSessionForTests, + type GetInitializationContextParams, +} from './environment'; +export type { + AnalyticsEventV2, + SmartAccountsKitFunctionCallParameters, + SmartAccountsKitFunctionCallPayload, + SmartAccountsKitFunctionCallProperties, + SmartAccountsKitBaseProperties, + SmartAccountsKitInitializedProperties, + SmartAccountsKitPayload, +} from './schema'; diff --git a/packages/analytics/src/schema.ts b/packages/analytics/src/schema.ts new file mode 100644 index 00000000..1dbd9226 --- /dev/null +++ b/packages/analytics/src/schema.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Analytics API field names */ +/** + * Types for MetaMask SDK analytics `/v2/events` payloads used by the Smart Accounts Kit. + */ + +/** Fields merged into every Smart Accounts Kit analytics event (set via globals and/or `trackInitialized`). */ +export type SmartAccountsKitBaseProperties = { + /** @description Version of the SDK. */ + sdk_version: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** + * @description Browser hostname (e.g. from `location.hostname`) when `platform` is `web-desktop`. + * Omitted in other platforms and when the hostname is not available. + */ + domain?: string; +}; + +/** @alias {@link SmartAccountsKitBaseProperties} — same shape as the initialization payload. */ +export type SmartAccountsKitInitializedProperties = + SmartAccountsKitBaseProperties; + +export type SmartAccountsKitPayload = { + namespace: 'metamask/smart-accounts-kit'; + event_name: 'smart_accounts_kit_initialized'; + properties: SmartAccountsKitBaseProperties; +}; + +/** Non-sensitive primitive fields only; callers must not pass secrets or PII. */ +export type SmartAccountsKitFunctionCallParameters = Record; + +export type SmartAccountsKitFunctionCallProperties = + SmartAccountsKitBaseProperties & { + /** Exported SDK function name (e.g. `createDelegation`, `sendUserOperationWithDelegationAction`). */ + function_name: string; + /** Optional safe subset of call arguments. */ + parameters?: SmartAccountsKitFunctionCallParameters; + }; + +export type SmartAccountsKitFunctionCallPayload = { + namespace: 'metamask/smart-accounts-kit'; + event_name: 'smart_accounts_kit_function_called'; + properties: SmartAccountsKitFunctionCallProperties; +}; + +export type AnalyticsEventV2 = + | SmartAccountsKitPayload + | SmartAccountsKitFunctionCallPayload; + +export type paths = { + '/v2/events': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': AnalyticsEventV2[]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + status?: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +}; diff --git a/packages/analytics/src/sender.test.ts b/packages/analytics/src/sender.test.ts new file mode 100644 index 00000000..e7fbe364 --- /dev/null +++ b/packages/analytics/src/sender.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import type { Mock } from 'vitest'; +import { describe, beforeEach, afterEach, it, vi, expect } from 'vitest'; + +import Sender from './sender'; + +describe('Sender', () => { + let sendFn: Mock<() => Promise>; + let sender: Sender; + + beforeEach(() => { + vi.useFakeTimers(); + sendFn = vi.fn().mockResolvedValue(undefined); + sender = new Sender({ + batchSize: 2, + baseTimeoutMs: 50, + maxFailureCount: 10, + maxTimeoutMs: 30_000, + sendFn, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('should flush after timeout', async () => { + sender.enqueue('event1'); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledWith(['event1']); + }); + + it('should flush twice with correct batch size', async () => { + sender.enqueue('event1'); + sender.enqueue('event2'); + sender.enqueue('event3'); + expect(sendFn).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(1); + expect(sendFn).toHaveBeenCalledWith(['event1', 'event2']); + + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(2); + expect(sendFn).toHaveBeenCalledWith(['event3']); + }); + + it('should handle failure (with exponential backoff) and reset base timeout after successful send', async () => { + let shouldSendFail = true; + sendFn = vi.fn().mockImplementation(async () => { + if (shouldSendFail) { + return Promise.reject(new Error('Failed')); + } + return Promise.resolve(); + }); + sender = new Sender({ + batchSize: 100, + baseTimeoutMs: 50, + maxFailureCount: 100, + maxTimeoutMs: 30_000, + sendFn, + }); + + shouldSendFail = true; + + sender.enqueue('event1'); + expect(sendFn).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(1); + expect(sendFn).toHaveBeenCalledWith(['event1']); + + shouldSendFail = false; + + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(2); + expect(sendFn).toHaveBeenCalledWith(['event1']); + + sender.enqueue('event2'); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(3); + expect(sendFn).toHaveBeenCalledWith(['event2']); + }); + + it('disables and purges when maxFailureCount is reached; enqueue is noop', async () => { + sendFn = vi.fn().mockRejectedValue(new Error('Failed')); + sender = new Sender({ + batchSize: 100, + baseTimeoutMs: 50, + maxFailureCount: 2, + maxTimeoutMs: 30_000, + sendFn, + }); + + sender.enqueue('event1'); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + expect(sendFn).toHaveBeenCalledTimes(2); + + sender.enqueue('event2'); + await vi.advanceTimersByTimeAsync(60_000); + expect(sendFn).toHaveBeenCalledTimes(2); + }); + + it('should handle concurrent sends properly', async () => { + let resolveSend!: (value?: unknown) => void; + sendFn = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => { + resolveSend = resolve; + }); + }); + sender = new Sender({ + batchSize: 100, + baseTimeoutMs: 1000, + maxFailureCount: 100, + maxTimeoutMs: 30_000, + sendFn, + }); + + sender.enqueue('event1'); + await vi.advanceTimersByTimeAsync(1000); + expect(sendFn).toHaveBeenCalledWith(['event1']); + expect(sendFn).toHaveBeenCalledTimes(1); + + sender.enqueue('event2'); + resolveSend(); + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(1000); + expect(sendFn).toHaveBeenCalledWith(['event2']); + expect(sendFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/analytics/src/sender.ts b/packages/analytics/src/sender.ts new file mode 100644 index 00000000..90ed2f08 --- /dev/null +++ b/packages/analytics/src/sender.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +type SenderOptions = { + batchSize: number; + baseTimeoutMs: number; + maxFailureCount: number; + maxTimeoutMs: number; + sendFn: (batch: T[]) => Promise; +}; + +/** + * Sender batches events and sends them to a server within a time window, + * with exponential backoff on errors. + */ +class Sender { + readonly #sendFn: (batch: T[]) => Promise; + + readonly #batchSize: number; + + readonly #baseTimeoutMs: number; + + readonly #maxFailureCount: number; + + readonly #maxTimeoutMs: number; + + #isDisabled = false; + + #batch: T[] = []; + + #failureCount: number = 0; + + #timeoutId: ReturnType | null = null; + + #isSending: boolean = false; + + constructor(options: SenderOptions) { + this.#batchSize = options.batchSize; + this.#baseTimeoutMs = options.baseTimeoutMs; + this.#maxFailureCount = options.maxFailureCount; + this.#sendFn = options.sendFn; + this.#maxTimeoutMs = options.maxTimeoutMs; + } + + public enqueue(item: T): void { + if (this.#isDisabled) { + return; + } + this.#batch.push(item); + this.#schedule(); + } + + #schedule(): void { + if (this.#isDisabled) { + return; + } + if (this.#batch.length > 0 && !this.#timeoutId) { + this.#timeoutId = setTimeout(() => { + this.#timeoutId = null; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#flush(); + }, this.#getTimeoutMs()); + } + } + + async #flush(): Promise { + if (this.#isDisabled || this.#isSending || this.#batch.length === 0) { + return; + } + + this.#isSending = true; + const current = [...this.#batch.slice(0, this.#batchSize)]; + this.#batch = this.#batch.slice(this.#batchSize); + + try { + await this.#sendFn(current); + this.#failureCount = 0; + } catch { + this.#failureCount += 1; + if (this.#failureCount >= this.#maxFailureCount) { + this.#isDisabled = true; + this.#batch = []; + if (this.#timeoutId !== null) { + clearTimeout(this.#timeoutId); + this.#timeoutId = null; + } + } else { + this.#batch = [...current, ...this.#batch]; + } + } finally { + this.#isSending = false; + this.#schedule(); + } + } + + #getTimeoutMs(): number { + return Math.min( + this.#baseTimeoutMs * 2 ** this.#failureCount, + this.#maxTimeoutMs, + ); + } +} + +export default Sender; diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json new file mode 100644 index 00000000..946266aa --- /dev/null +++ b/packages/analytics/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../shared/config/base.tsconfig.json", + "exclude": ["./node_modules/**/*", "./dist/**/*"], + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist" + } +} diff --git a/packages/analytics/tsup.config.ts b/packages/analytics/tsup.config.ts new file mode 100644 index 00000000..d1d31001 --- /dev/null +++ b/packages/analytics/tsup.config.ts @@ -0,0 +1,12 @@ +import type { Options } from 'tsup'; +import config from '../../shared/config/base.tsup.config'; + +const options: Options = { + ...config, + entry: ['src/index.ts'], + dts: { + entry: ['src/index.ts'], + }, +}; + +export default options; diff --git a/packages/analytics/vitest.config.ts b/packages/analytics/vitest.config.ts new file mode 100644 index 00000000..8e730d50 --- /dev/null +++ b/packages/analytics/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/packages/smart-accounts-kit/README.md b/packages/smart-accounts-kit/README.md index c8df5c71..2a535b15 100644 --- a/packages/smart-accounts-kit/README.md +++ b/packages/smart-accounts-kit/README.md @@ -19,15 +19,17 @@ The MetaMask Smart Accounts Kit is a [Viem](https://viem.sh)-based collection of Yarn: ```sh -yarn add @metamask/smart-accounts-kit +yarn add @metamask/smart-accounts-kit viem ``` Npm: ```sh -npm install @metamask/smart-accounts-kit +npm install @metamask/smart-accounts-kit viem ``` +`viem` is a peer dependency; install a compatible version alongside the kit. + ## Overview --- diff --git a/packages/smart-accounts-kit/package.json b/packages/smart-accounts-kit/package.json index ff9c27ea..b4bc7d07 100644 --- a/packages/smart-accounts-kit/package.json +++ b/packages/smart-accounts-kit/package.json @@ -95,7 +95,12 @@ "engines": { "node": "^18.18 || >=20" }, - "sideEffects": false, + "sideEffects": [ + "./dist/index.cjs", + "./dist/index.mjs", + "./dist/*/index.cjs", + "./dist/*/index.mjs" + ], "scripts": { "build": "yarn typecheck && tsup", "typecheck": "tsc --noEmit", @@ -129,6 +134,7 @@ "@metamask/delegation-core": "^0.4.0", "@metamask/delegation-deployments": "^0.17.0", "buffer": "^6.0.3", + "openapi-fetch": "^0.13.5", "ox": "0.8.1" }, "devDependencies": { @@ -138,6 +144,7 @@ "@metamask/eslint-config": "14.1.0", "@metamask/eslint-config-nodejs": "14.0.0", "@metamask/eslint-config-typescript": "14.0.0", + "@metamask/smart-accounts-kit-analytics": "workspace:^", "@types/node": "^20.19.0", "@types/sinon": "^17.0.3", "@vitest/coverage-v8": "3.2.4", diff --git a/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts b/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts index 3a974f67..1186ff50 100644 --- a/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts +++ b/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts @@ -1,5 +1,6 @@ import type { Client, Transport, Chain, Account } from 'viem'; +import { trackSmartAccountsKitFunctionCall } from '../analytics'; import type { SmartAccountsEnvironment } from '../types'; import { caveatEnforcerActions, @@ -37,6 +38,10 @@ export function createCaveatEnforcerClient< client: Client; environment: SmartAccountsEnvironment; }): CaveatEnforcerClient { + trackSmartAccountsKitFunctionCall('createCaveatEnforcerClient', { + chainId: client.chain?.id ?? null, + }); + return client.extend(caveatEnforcerActions({ environment })); } diff --git a/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts b/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts index 0677dfb7..f0c4b552 100644 --- a/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts +++ b/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts @@ -17,6 +17,7 @@ import type { SmartAccount, } from 'viem/account-abstraction'; +import { trackSmartAccountsKitFunctionCall } from '../analytics'; import { encodeDelegations } from '../delegation'; import { createExecution, @@ -56,6 +57,10 @@ export async function sendTransactionWithDelegationAction< client: WalletClient, args: SendTransactionWithDelegationParameters, ) { + trackSmartAccountsKitFunctionCall('sendTransactionWithDelegationAction', { + chainId: client.chain?.id ?? null, + }); + if (!args.to) { throw new Error( '`to` is required. `sendTransactionWithDelegation` cannot be used to deploy contracts.', @@ -161,6 +166,12 @@ export async function sendUserOperationWithDelegationAction< TAccountOverride >, ) { + trackSmartAccountsKitFunctionCall('sendUserOperationWithDelegationAction', { + chainId: client.chain?.id ?? null, + callCount: parameters.calls.length, + hasDependencies: parameters.dependencies !== undefined, + }); + if (parameters.dependencies) { const { publicClient } = parameters; diff --git a/packages/smart-accounts-kit/src/actions/erc7715GetGrantedExecutionPermissionsAction.ts b/packages/smart-accounts-kit/src/actions/erc7715GetGrantedExecutionPermissionsAction.ts index b7b4b34f..a1e8c9ad 100644 --- a/packages/smart-accounts-kit/src/actions/erc7715GetGrantedExecutionPermissionsAction.ts +++ b/packages/smart-accounts-kit/src/actions/erc7715GetGrantedExecutionPermissionsAction.ts @@ -1,3 +1,4 @@ +import { trackSmartAccountsKitFunctionCall } from '../analytics'; import { permissionResponsesFromRpc } from './erc7715Mapping'; import type { GetGrantedExecutionPermissionsResult, @@ -24,6 +25,13 @@ export type { GetGrantedExecutionPermissionsResult } from './erc7715Types'; export async function erc7715GetGrantedExecutionPermissionsAction( client: MetaMaskExtensionClient, ): Promise { + trackSmartAccountsKitFunctionCall( + 'erc7715GetGrantedExecutionPermissionsAction', + { + chainId: client.chain?.id ?? null, + }, + ); + const result = await client.request( { method: 'wallet_getGrantedExecutionPermissions', diff --git a/packages/smart-accounts-kit/src/actions/erc7715GetSupportedExecutionPermissionsAction.ts b/packages/smart-accounts-kit/src/actions/erc7715GetSupportedExecutionPermissionsAction.ts index d7cdf89e..7bf354bb 100644 --- a/packages/smart-accounts-kit/src/actions/erc7715GetSupportedExecutionPermissionsAction.ts +++ b/packages/smart-accounts-kit/src/actions/erc7715GetSupportedExecutionPermissionsAction.ts @@ -1,3 +1,4 @@ +import { trackSmartAccountsKitFunctionCall } from '../analytics'; import { rpcSupportedPermissionsToDeveloper } from './erc7715Mapping'; import type { GetSupportedExecutionPermissionsResult, @@ -33,6 +34,13 @@ export type { GetSupportedExecutionPermissionsResult } from './erc7715Types'; export async function erc7715GetSupportedExecutionPermissionsAction( client: MetaMaskExtensionClient, ): Promise { + trackSmartAccountsKitFunctionCall( + 'erc7715GetSupportedExecutionPermissionsAction', + { + chainId: client.chain?.id ?? null, + }, + ); + const result = await client.request( { method: 'wallet_getSupportedExecutionPermissions', diff --git a/packages/smart-accounts-kit/src/actions/erc7715RequestExecutionPermissionsAction.ts b/packages/smart-accounts-kit/src/actions/erc7715RequestExecutionPermissionsAction.ts index 1aae2cad..266240ba 100644 --- a/packages/smart-accounts-kit/src/actions/erc7715RequestExecutionPermissionsAction.ts +++ b/packages/smart-accounts-kit/src/actions/erc7715RequestExecutionPermissionsAction.ts @@ -1,3 +1,4 @@ +import { trackSmartAccountsKitFunctionCall } from '../analytics'; import { permissionRequestToRpc, permissionResponsesFromRpc, @@ -47,6 +48,13 @@ export async function erc7715RequestExecutionPermissionsAction( client: MetaMaskExtensionClient, parameters: RequestExecutionPermissionsParameters, ): Promise { + trackSmartAccountsKitFunctionCall( + 'erc7715RequestExecutionPermissionsAction', + { + chainId: client.chain?.id ?? null, + requestCount: parameters.length, + }, + ); const formattedPermissionRequest = parameters.map(permissionRequestToRpc); const result = await client.request( diff --git a/packages/smart-accounts-kit/src/actions/getCaveatAvailableAmount.ts b/packages/smart-accounts-kit/src/actions/getCaveatAvailableAmount.ts index 09a19184..4f55bb35 100644 --- a/packages/smart-accounts-kit/src/actions/getCaveatAvailableAmount.ts +++ b/packages/smart-accounts-kit/src/actions/getCaveatAvailableAmount.ts @@ -1,5 +1,6 @@ import type { Address, Hex, Client } from 'viem'; +import { trackSmartAccountsKitFunctionCall } from '../analytics'; import { hashDelegation } from '../delegation'; import * as ERC20PeriodTransferEnforcer from '../DelegationFramework/ERC20PeriodTransferEnforcer'; import * as ERC20StreamingEnforcer from '../DelegationFramework/ERC20StreamingEnforcer'; @@ -125,6 +126,12 @@ export async function getErc20PeriodTransferEnforcerAvailableAmount( environment: SmartAccountsEnvironment, params: CaveatEnforcerParams, ): Promise { + trackSmartAccountsKitFunctionCall( + 'getErc20PeriodTransferEnforcerAvailableAmount', + { + chainId: client.chain?.id ?? null, + }, + ); const enforcerName = 'ERC20PeriodTransferEnforcer'; const delegationManager = getDelegationManager(environment); @@ -162,6 +169,12 @@ export async function getErc20StreamingEnforcerAvailableAmount( environment: SmartAccountsEnvironment, params: CaveatEnforcerParams, ): Promise { + trackSmartAccountsKitFunctionCall( + 'getErc20StreamingEnforcerAvailableAmount', + { + chainId: client.chain?.id ?? null, + }, + ); const enforcerName = 'ERC20StreamingEnforcer'; const delegationManager = getDelegationManager(environment); const enforcerAddress = getEnforcerAddress({ @@ -198,6 +211,12 @@ export async function getMultiTokenPeriodEnforcerAvailableAmount( environment: SmartAccountsEnvironment, params: CaveatEnforcerParams, ): Promise { + trackSmartAccountsKitFunctionCall( + 'getMultiTokenPeriodEnforcerAvailableAmount', + { + chainId: client.chain?.id ?? null, + }, + ); const enforcerName = 'MultiTokenPeriodEnforcer'; const delegationManager = getDelegationManager(environment); const enforcerAddress = getEnforcerAddress({ @@ -235,6 +254,12 @@ export async function getNativeTokenPeriodTransferEnforcerAvailableAmount( environment: SmartAccountsEnvironment, params: CaveatEnforcerParams, ): Promise { + trackSmartAccountsKitFunctionCall( + 'getNativeTokenPeriodTransferEnforcerAvailableAmount', + { + chainId: client.chain?.id ?? null, + }, + ); const enforcerName = 'NativeTokenPeriodTransferEnforcer'; const delegationManager = getDelegationManager(environment); const enforcerAddress = getEnforcerAddress({ @@ -271,6 +296,12 @@ export async function getNativeTokenStreamingEnforcerAvailableAmount( environment: SmartAccountsEnvironment, params: CaveatEnforcerParams, ): Promise { + trackSmartAccountsKitFunctionCall( + 'getNativeTokenStreamingEnforcerAvailableAmount', + { + chainId: client.chain?.id ?? null, + }, + ); const enforcerName = 'NativeTokenStreamingEnforcer'; const delegationManager = getDelegationManager(environment); const enforcerAddress = getEnforcerAddress({ diff --git a/packages/smart-accounts-kit/src/actions/index.ts b/packages/smart-accounts-kit/src/actions/index.ts index 103d31a7..be9b3547 100644 --- a/packages/smart-accounts-kit/src/actions/index.ts +++ b/packages/smart-accounts-kit/src/actions/index.ts @@ -1,6 +1,7 @@ import type { Client, WalletClient } from 'viem'; import type { BundlerClient } from 'viem/account-abstraction'; +import { ensureSmartAccountsKitAnalyticsBootstrapped } from '../analytics'; import type { SendTransactionWithDelegationParameters, SendUserOperationWithDelegationParameters, @@ -17,6 +18,8 @@ import type { RequestExecutionPermissionsParameters, } from './erc7715RequestExecutionPermissionsAction'; +ensureSmartAccountsKitAnalyticsBootstrapped(); + export { // Individual action functions getErc20PeriodTransferEnforcerAvailableAmount, diff --git a/packages/smart-accounts-kit/src/actions/infuraBundlerClient.ts b/packages/smart-accounts-kit/src/actions/infuraBundlerClient.ts index 08870b9e..680ca554 100644 --- a/packages/smart-accounts-kit/src/actions/infuraBundlerClient.ts +++ b/packages/smart-accounts-kit/src/actions/infuraBundlerClient.ts @@ -6,6 +6,8 @@ import { type SmartAccount, } from 'viem/account-abstraction'; +import { trackSmartAccountsKitFunctionCall } from '../analytics'; + /** * Gas price tiers returned by pimlico_getUserOperationGasPrice */ @@ -129,6 +131,9 @@ export function createInfuraBundlerClient< >( config: BundlerClientConfig, ): InfuraBundlerClient { + trackSmartAccountsKitFunctionCall('createInfuraBundlerClient', { + chainId: config.chain?.id ?? null, + }); // Create the base bundler client using viem's function const baseBundlerClient = createBundlerClient(config); diff --git a/packages/smart-accounts-kit/src/analytics.ts b/packages/smart-accounts-kit/src/analytics.ts new file mode 100644 index 00000000..406ba6b5 --- /dev/null +++ b/packages/smart-accounts-kit/src/analytics.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/naming-convention -- process.env */ +/* eslint-disable camelcase -- sdk_version matches analytics event payload keys */ +import { + Analytics, + METAMASK_ANALYTICS_ENDPOINT, + getInitializationContext, + type SmartAccountsKitFunctionCallParameters, +} from '@metamask/smart-accounts-kit-analytics'; + +import { version as sdk_version } from '../package.json'; + +/** + * Whether Do Not Track (browser or `DO_NOT_TRACK` in Node) disables analytics. + * + * @returns True when DNT is enabled. + */ +function isAnalyticsDisabled(): boolean { + /* eslint-disable no-restricted-globals */ + let env: NodeJS.ProcessEnv | undefined; + if (typeof process !== 'undefined') { + env = process.env; + } + + // disable analytics in CI + if (env?.CI === 'true') { + return true; + } + + // disable analytics if any DNT indicator is set + let dntIndicator: string | undefined; + if (typeof window === 'undefined') { + dntIndicator = env?.DO_NOT_TRACK; + } else { + dntIndicator = + navigator.doNotTrack ?? (window as { doNotTrack?: string }).doNotTrack; + } + /* eslint-enable no-restricted-globals */ + + return ( + dntIndicator === '1' || dntIndicator === 'yes' || dntIndicator === 'true' + ); +} + +export const analytics = new Analytics(METAMASK_ANALYTICS_ENDPOINT); + +/** + * Records `smart_accounts_kit_function_called` when analytics is enabled and session exists. + * Pass only non-sensitive primitive fields in `parameters`. + * + * @param functionName - Stable SDK entry identifier (e.g. `createDelegation`, `aggregateSignature`). + * @param parameters - Optional safe argument metadata; use camelCase keys, no secrets or PII. + */ +export function trackSmartAccountsKitFunctionCall( + functionName: string, + parameters?: SmartAccountsKitFunctionCallParameters, +): void { + try { + analytics.trackSdkFunctionCall(functionName, parameters); + // eslint-disable-next-line no-empty, @typescript-eslint/no-unused-vars + } catch (_error) {} +} + +let hasBootstrapped = false; + +/** + * One-time internal setup: session base (stable anon_id, platform, domain), enable client, + * emit `smart_accounts_kit_initialized`. No-op when `DO_NOT_TRACK` is `true`. + * + * Kit source files should import the same singleton: `import { analytics } from '@metamask/smart-accounts-kit-analytics'`. + * Do not use `setGlobalProperty` before {@link getInitializationContext} — session must exist first. + */ +export function ensureSmartAccountsKitAnalyticsBootstrapped(): void { + if (hasBootstrapped) { + return; + } + + hasBootstrapped = true; + + if (isAnalyticsDisabled()) { + return; + } + + try { + getInitializationContext({ sdk_version }); + analytics.enable(); + analytics.trackInitialized(); + // eslint-disable-next-line no-empty, @typescript-eslint/no-unused-vars + } catch (_error) {} +} diff --git a/packages/smart-accounts-kit/src/caveats.ts b/packages/smart-accounts-kit/src/caveats.ts index 0c2c9f57..770fc725 100644 --- a/packages/smart-accounts-kit/src/caveats.ts +++ b/packages/smart-accounts-kit/src/caveats.ts @@ -44,8 +44,10 @@ export const createCaveat = ( enforcer: Hex, terms: Hex, args: Hex = '0x00', -): Caveat => ({ - enforcer, - terms, - args, -}); +): Caveat => { + return { + enforcer, + terms, + args, + }; +}; diff --git a/packages/smart-accounts-kit/src/contracts/index.ts b/packages/smart-accounts-kit/src/contracts/index.ts index 38555c01..add7fbd3 100644 --- a/packages/smart-accounts-kit/src/contracts/index.ts +++ b/packages/smart-accounts-kit/src/contracts/index.ts @@ -1,3 +1,4 @@ +import { ensureSmartAccountsKitAnalyticsBootstrapped } from '../analytics'; import * as DelegationManager from '../DelegationFramework/DelegationManager'; import * as DeleGatorCore from '../DelegationFramework/DeleGatorCore'; import * as EIP712 from '../DelegationFramework/EIP712'; @@ -19,6 +20,8 @@ import * as Pausable from '../DelegationFramework/Pausable'; import * as SimpleFactory from '../DelegationFramework/SimpleFactory'; import * as SpecificActionERC20TransferBatchEnforcer from '../DelegationFramework/SpecificActionERC20TransferBatchEnforcer'; +ensureSmartAccountsKitAnalyticsBootstrapped(); + export { isContractDeployed, isImplementationExpected, diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index 4349b36f..0de719e7 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -13,6 +13,7 @@ import { toHex, getAddress, isHex } from 'viem'; import type { TypedData, AbiParameter, Address, Hex } from 'viem'; import { signTypedData } from 'viem/accounts'; +import { trackSmartAccountsKitFunctionCall } from './analytics'; import { type Caveats, resolveCaveats } from './caveatBuilder'; import type { ScopeConfig } from './caveatBuilder/scope'; import { CAVEAT_ABI_TYPE_COMPONENTS } from './caveats'; @@ -247,6 +248,30 @@ export const resolveAuthority = (parentDelegation?: Delegation | Hex): Hex => { return hashDelegation(parentDelegation); }; +const getCaveatNames = ({ + caveats, + environment: { caveatEnforcers }, +}: { + caveats: DelegationStruct['caveats']; + environment: SmartAccountsEnvironment; +}): string[] => { + if (Array.isArray(caveats)) { + const knownEnforcers = Object.entries(caveatEnforcers).map( + ([name, address]) => ({ name, address: address.toLowerCase() }), + ); + + return caveats.map((caveat) => { + const enforcerAddressLowercase = caveat.enforcer?.toLowerCase(); + const matchingCaveat = knownEnforcers.find( + ({ address }) => address === enforcerAddressLowercase, + ); + return matchingCaveat?.name ?? 'Unknown'; + }); + } + + return []; +}; + /** * Creates a delegation with specific delegate. * @@ -256,11 +281,22 @@ export const resolveAuthority = (parentDelegation?: Delegation | Hex): Hex => { export const createDelegation = ( options: CreateDelegationOptions, ): Delegation => { + const caveats = resolveCaveats(options); + + trackSmartAccountsKitFunctionCall('createDelegation', { + hasParentDelegation: options.parentDelegation !== undefined, + scope: options.scope, + caveatNames: getCaveatNames({ + caveats, + environment: options.environment, + }), + }); + return { delegate: options.to, delegator: options.from, authority: resolveAuthority(options.parentDelegation), - caveats: resolveCaveats(options), + caveats, salt: options.salt ?? '0x00', signature: '0x', }; @@ -275,11 +311,22 @@ export const createDelegation = ( export const createOpenDelegation = ( options: CreateOpenDelegationOptions, ): Delegation => { + const caveats = resolveCaveats(options); + + trackSmartAccountsKitFunctionCall('createOpenDelegation', { + hasParentDelegation: options.parentDelegation !== undefined, + scope: options.scope, + caveatNames: getCaveatNames({ + caveats, + environment: options.environment, + }), + }); + return { delegate: ANY_BENEFICIARY, delegator: options.from, authority: resolveAuthority(options.parentDelegation), - caveats: resolveCaveats(options), + caveats, salt: options.salt ?? '0x00', signature: '0x', }; diff --git a/packages/smart-accounts-kit/src/executions.ts b/packages/smart-accounts-kit/src/executions.ts index 07c9478c..6ec67c7a 100644 --- a/packages/smart-accounts-kit/src/executions.ts +++ b/packages/smart-accounts-kit/src/executions.ts @@ -30,11 +30,13 @@ export const createExecution = ({ target, value = 0n, callData = '0x', -}: CreateExecutionArgs): ExecutionStruct => ({ - target, - value, - callData, -}); +}: CreateExecutionArgs): ExecutionStruct => { + return { + target, + value, + callData, + }; +}; // Encoded modes // https://github.com/erc7579/erc7579-implementation/blob/main/src/lib/ModeLib.sol diff --git a/packages/smart-accounts-kit/src/experimental/delegationStorage.ts b/packages/smart-accounts-kit/src/experimental/delegationStorage.ts index 3d3165ad..7736fc89 100644 --- a/packages/smart-accounts-kit/src/experimental/delegationStorage.ts +++ b/packages/smart-accounts-kit/src/experimental/delegationStorage.ts @@ -1,5 +1,6 @@ import { type Hex, toHex } from 'viem'; +import { trackSmartAccountsKitFunctionCall } from '../analytics'; import { hashDelegation } from '../delegation'; import type { Delegation } from '../types'; @@ -32,6 +33,23 @@ export const DelegationStorageEnvironment: { prod: { apiUrl: 'https://passkeys.api.cx.metamask.io' }, }; +/** + * Returns the DelegationStorageEnvironment ('dev' or 'prod') for a given apiUrl, + * or 'custom' if the apiUrl does not match a known environment. + * + * @param apiUrl - The API URL to check. + * @returns The environment key ('dev' or 'prod'), or 'custom'. + */ +function getDelegationStorageEnvironment( + apiUrl: string, +): 'dev' | 'prod' | 'custom' { + const matchingEnvironment = Object.entries(DelegationStorageEnvironment).find( + ([_, env]) => env.apiUrl === apiUrl, + ) as [keyof typeof DelegationStorageEnvironment, Environment] | undefined; + + return matchingEnvironment ? matchingEnvironment[0] : 'custom'; +} + export type Environment = { apiUrl: string; }; @@ -55,6 +73,13 @@ export class DelegationStorageClient { constructor(config: DelegationStorageConfig) { const { apiUrl } = config.environment; + trackSmartAccountsKitFunctionCall( + 'experimental.DelegationStorageClient.constructor', + { + environment: getDelegationStorageEnvironment(apiUrl), + }, + ); + if (apiUrl.endsWith(this.#apiVersionPrefix)) { this.#apiUrl = apiUrl; } else { @@ -99,6 +124,15 @@ export class DelegationStorageClient { async getDelegationChain( leafDelegationOrDelegationHash: Hex | Delegation, ): Promise { + trackSmartAccountsKitFunctionCall( + 'experimental.DelegationStorageClient.getDelegationChain', + { + inputKind: + typeof leafDelegationOrDelegationHash === 'string' + ? 'hash' + : 'delegation', + }, + ); const leafDelegationHash = typeof leafDelegationOrDelegationHash === 'string' ? leafDelegationOrDelegationHash @@ -140,6 +174,10 @@ export class DelegationStorageClient { deleGatorAddress: Hex, filterMode = DelegationStoreFilter.Received, ): Promise { + trackSmartAccountsKitFunctionCall( + 'experimental.DelegationStorageClient.fetchDelegations', + { filterMode }, + ); const response = await this.#fetcher( `${this.#apiUrl}/delegation/accounts/${deleGatorAddress}?filter=${filterMode}`, { @@ -167,6 +205,10 @@ export class DelegationStorageClient { * @returns A promise that resolves to the delegation hash indicating successful storage. */ async storeDelegation(delegation: Delegation): Promise { + trackSmartAccountsKitFunctionCall( + 'experimental.DelegationStorageClient.storeDelegation', + { caveatCount: delegation.caveats.length }, + ); if (!delegation.signature || delegation.signature === '0x') { throw new Error('Delegation must be signed to be stored'); } diff --git a/packages/smart-accounts-kit/src/experimental/index.ts b/packages/smart-accounts-kit/src/experimental/index.ts index a69cbd5a..b2ad30bd 100644 --- a/packages/smart-accounts-kit/src/experimental/index.ts +++ b/packages/smart-accounts-kit/src/experimental/index.ts @@ -1,5 +1,10 @@ +import { ensureSmartAccountsKitAnalyticsBootstrapped } from '../analytics'; + +ensureSmartAccountsKitAnalyticsBootstrapped(); + export { DelegationStorageClient, + DelegationStorageEnvironment, type DelegationStoreFilter, type Environment, type DelegationStorageConfig, diff --git a/packages/smart-accounts-kit/src/index.ts b/packages/smart-accounts-kit/src/index.ts index 5ac166f5..053156bf 100644 --- a/packages/smart-accounts-kit/src/index.ts +++ b/packages/smart-accounts-kit/src/index.ts @@ -1,3 +1,7 @@ +import { ensureSmartAccountsKitAnalyticsBootstrapped } from './analytics'; + +ensureSmartAccountsKitAnalyticsBootstrapped(); + export { toMetaMaskSmartAccount } from './toMetaMaskSmartAccount'; export { diff --git a/packages/smart-accounts-kit/src/signatures.ts b/packages/smart-accounts-kit/src/signatures.ts index 2ece2ec4..815e3a0b 100644 --- a/packages/smart-accounts-kit/src/signatures.ts +++ b/packages/smart-accounts-kit/src/signatures.ts @@ -1,6 +1,8 @@ import type { Address, Hex } from 'viem'; import { concat } from 'viem'; +import { trackSmartAccountsKitFunctionCall } from './analytics'; + const signatureTypes = ['ECDSA'] as const; export type SignatureType = (typeof signatureTypes)[number]; @@ -26,6 +28,10 @@ export const aggregateSignature = ({ }: { signatures: PartialSignature[]; }): Hex => { + trackSmartAccountsKitFunctionCall('aggregateSignature', { + signatureCount: signatures.length, + }); + if (signatures.length === 0) { return '0x'; } diff --git a/packages/smart-accounts-kit/src/toMetaMaskSmartAccount.ts b/packages/smart-accounts-kit/src/toMetaMaskSmartAccount.ts index b3949de4..db5b2bce 100644 --- a/packages/smart-accounts-kit/src/toMetaMaskSmartAccount.ts +++ b/packages/smart-accounts-kit/src/toMetaMaskSmartAccount.ts @@ -11,6 +11,7 @@ import { } from 'viem/account-abstraction'; import { isValid7702Implementation } from './actions/isValid7702Implementation'; +import { trackSmartAccountsKitFunctionCall } from './analytics'; import { Implementation } from './constants'; import { getCounterfactualAccountData } from './counterfactualAccountData'; import { @@ -55,6 +56,13 @@ export async function toMetaMaskSmartAccount< implementation, } = params; + trackSmartAccountsKitFunctionCall('toMetaMaskSmartAccount', { + implementation, + hasAddress: params.address !== undefined, + hasEnvironment: params.environment !== undefined, + chainId: chain?.id ?? null, + }); + if (!chain) { throw new Error('Chain not specified'); } diff --git a/packages/smart-accounts-kit/src/utils/index.ts b/packages/smart-accounts-kit/src/utils/index.ts index 6ed7c103..ef4924ed 100644 --- a/packages/smart-accounts-kit/src/utils/index.ts +++ b/packages/smart-accounts-kit/src/utils/index.ts @@ -1,3 +1,7 @@ +import { ensureSmartAccountsKitAnalyticsBootstrapped } from '../analytics'; + +ensureSmartAccountsKitAnalyticsBootstrapped(); + export { encodeDelegations, decodeDelegations, diff --git a/packages/smart-accounts-kit/tsup.config.ts b/packages/smart-accounts-kit/tsup.config.ts index f924cc8c..50e188e5 100644 --- a/packages/smart-accounts-kit/tsup.config.ts +++ b/packages/smart-accounts-kit/tsup.config.ts @@ -3,6 +3,8 @@ import config from '../../shared/config/base.tsup.config'; const options: Options = { ...config, + // Bundle internal analytics into the published kit (not a separate install). + noExternal: ['@metamask/smart-accounts-kit-analytics'], entry: [ 'src/index.ts', 'src/experimental/index.ts', @@ -17,7 +19,7 @@ const options: Options = { 'src/utils/index.ts', 'src/contracts/index.ts', 'src/actions/index.ts', - ], + ] }, }; diff --git a/packages/smart-accounts-kit/vitest.config.ts b/packages/smart-accounts-kit/vitest.config.ts index 8e730d50..1667807c 100644 --- a/packages/smart-accounts-kit/vitest.config.ts +++ b/packages/smart-accounts-kit/vitest.config.ts @@ -4,5 +4,9 @@ export default defineConfig({ test: { globals: true, environment: 'node', + env: { + // Don't track analytics during unit tests. + DO_NOT_TRACK: 'true', + }, }, }); diff --git a/yarn.lock b/yarn.lock index d183819e..b925d6aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -770,6 +770,24 @@ __metadata: languageName: node linkType: hard +"@metamask/smart-accounts-kit-analytics@workspace:^, @metamask/smart-accounts-kit-analytics@workspace:packages/analytics": + version: 0.0.0-use.local + resolution: "@metamask/smart-accounts-kit-analytics@workspace:packages/analytics" + dependencies: + "@metamask/auto-changelog": "npm:^5.0.2" + "@types/node": "npm:^20.19.0" + eslint: "npm:^9.39.2" + nock: "npm:^14.0.4" + openapi-fetch: "npm:^0.13.5" + prettier: "npm:^3.5.3" + tsup: "npm:^8.5.0" + typescript: "npm:5.5.4" + vitest: "npm:^3.2.4" + peerDependencies: + openapi-fetch: ^0.13.5 + languageName: unknown + linkType: soft + "@metamask/smart-accounts-kit@workspace:*, @metamask/smart-accounts-kit@workspace:packages/smart-accounts-kit": version: 0.0.0-use.local resolution: "@metamask/smart-accounts-kit@workspace:packages/smart-accounts-kit" @@ -784,6 +802,7 @@ __metadata: "@metamask/eslint-config": "npm:14.1.0" "@metamask/eslint-config-nodejs": "npm:14.0.0" "@metamask/eslint-config-typescript": "npm:14.0.0" + "@metamask/smart-accounts-kit-analytics": "workspace:^" "@types/node": "npm:^20.19.0" "@types/sinon": "npm:^17.0.3" "@vitest/coverage-v8": "npm:3.2.4" @@ -797,6 +816,7 @@ __metadata: eslint-plugin-n: "npm:^17.10.3" eslint-plugin-prettier: "npm:^5.5.4" eslint-plugin-promise: "npm:^7.1.0" + openapi-fetch: "npm:^0.13.5" ox: "npm:0.8.1" prettier: "npm:^3.5.3" sinon: "npm:^18.0.0" @@ -856,6 +876,20 @@ __metadata: languageName: node linkType: hard +"@mswjs/interceptors@npm:^0.41.0": + version: 0.41.3 + resolution: "@mswjs/interceptors@npm:0.41.3" + dependencies: + "@open-draft/deferred-promise": "npm:^2.2.0" + "@open-draft/logger": "npm:^0.3.0" + "@open-draft/until": "npm:^2.0.0" + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.3" + strict-event-emitter: "npm:^0.5.1" + checksum: 10/96b6c535fd27c8aed57f2dad380ea31e09026bf6ef960420bc0e30a2ccff269c8121f21f20423f4edd2ef1ed7db6173295950a3c4529693e6bca12eca1be4347 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.11": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" @@ -1158,6 +1192,30 @@ __metadata: languageName: node linkType: hard +"@open-draft/deferred-promise@npm:^2.2.0": + version: 2.2.0 + resolution: "@open-draft/deferred-promise@npm:2.2.0" + checksum: 10/bc3bb1668a555bb87b33383cafcf207d9561e17d2ca0d9e61b7ce88e82b66e36a333d3676c1d39eb5848022c03c8145331fcdc828ba297f88cb1de9c5cef6c19 + languageName: node + linkType: hard + +"@open-draft/logger@npm:^0.3.0": + version: 0.3.0 + resolution: "@open-draft/logger@npm:0.3.0" + dependencies: + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.0" + checksum: 10/7a280f170bcd4e91d3eedbefe628efd10c3bd06dd2461d06a7fdbced89ef457a38785847f88cc630fb4fd7dfa176d6f77aed17e5a9b08000baff647433b5ff78 + languageName: node + linkType: hard + +"@open-draft/until@npm:^2.0.0": + version: 2.1.0 + resolution: "@open-draft/until@npm:2.1.0" + checksum: 10/622be42950afc8e89715d0fd6d56cbdcd13e36625e23b174bd3d9f06f80e25f9adf75d6698af93bca1e1bf465b9ce00ec05214a12189b671fb9da0f58215b6f4 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -3970,6 +4028,13 @@ __metadata: languageName: node linkType: hard +"is-node-process@npm:^1.2.0": + version: 1.2.0 + resolution: "is-node-process@npm:1.2.0" + checksum: 10/930765cdc6d81ab8f1bbecbea4a8d35c7c6d88a3ff61f3630e0fc7f22d624d7661c1df05c58547d0eb6a639dfa9304682c8e342c4113a6ed51472b704cee2928 + languageName: node + linkType: hard + "is-number@npm:^7.0.0": version: 7.0.0 resolution: "is-number@npm:7.0.0" @@ -4142,6 +4207,13 @@ __metadata: languageName: node linkType: hard +"json-stringify-safe@npm:^5.0.1": + version: 5.0.1 + resolution: "json-stringify-safe@npm:5.0.1" + checksum: 10/59169a081e4eeb6f9559ae1f938f656191c000e0512aa6df9f3c8b2437a4ab1823819c6b9fd1818a4e39593ccfd72e9a051fdd3e2d1e340ed913679e888ded8c + languageName: node + linkType: hard + "json5@npm:^2.2.2": version: 2.2.3 resolution: "json5@npm:2.2.3" @@ -4617,6 +4689,17 @@ __metadata: languageName: node linkType: hard +"nock@npm:^14.0.4": + version: 14.0.11 + resolution: "nock@npm:14.0.11" + dependencies: + "@mswjs/interceptors": "npm:^0.41.0" + json-stringify-safe: "npm:^5.0.1" + propagate: "npm:^2.0.0" + checksum: 10/2fc579f3bee5148ebfacdfc7588a1f45205b139dcc6e32a5bef436f74f479383c7445a9812f433908600f62a0e142ff4bbbe7da7d5e9ed1781bad278b792cf98 + languageName: node + linkType: hard + "node-addon-api@npm:^2.0.0": version: 2.0.2 resolution: "node-addon-api@npm:2.0.2" @@ -4846,6 +4929,22 @@ __metadata: languageName: node linkType: hard +"openapi-fetch@npm:^0.13.5": + version: 0.13.8 + resolution: "openapi-fetch@npm:0.13.8" + dependencies: + openapi-typescript-helpers: "npm:^0.0.15" + checksum: 10/fed630452ac2d6abc680402651d848b7377b651164ca2be61a8c5e1fc89e41b09c928ba9dc92cf7c7ad2d400b3fbe5af380165303f293501dc08cefa4c0f92fd + languageName: node + linkType: hard + +"openapi-typescript-helpers@npm:^0.0.15": + version: 0.0.15 + resolution: "openapi-typescript-helpers@npm:0.0.15" + checksum: 10/63f8f0b8464aed3e5c6910428bd14839bd5c1dd6ddf841bcea9d5f536a6e03e942a028202920da1a8b1ed9e4304c6fca14943d01a8adff2942d1254a229b8c70 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -4860,6 +4959,13 @@ __metadata: languageName: node linkType: hard +"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3": + version: 1.4.3 + resolution: "outvariant@npm:1.4.3" + checksum: 10/3a7582745850cb344d49641867a4c080858c54f4091afd91b9c0765ba6e471c2bc841348f0fff344845ddd0a4db42fd5d68c6f7ebaf32d4b676a3a9987b2488a + languageName: node + linkType: hard + "ox@npm:0.8.1": version: 0.8.1 resolution: "ox@npm:0.8.1" @@ -5149,6 +5255,13 @@ __metadata: languageName: node linkType: hard +"propagate@npm:^2.0.0": + version: 2.0.1 + resolution: "propagate@npm:2.0.1" + checksum: 10/8c761c16e8232f82f6d015d3e01e8bd4109f47ad804f904d950f6fe319813b448ca112246b6bfdc182b400424b155b0b7c4525a9bb009e6fa950200157569c14 + languageName: node + linkType: hard + "proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -5682,6 +5795,13 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter@npm:^0.5.1": + version: 0.5.1 + resolution: "strict-event-emitter@npm:0.5.1" + checksum: 10/25c84d88be85940d3547db665b871bfecea4ea0bedfeb22aae8db48126820cfb2b0bc2fba695392592a09b1aa36b686d6eede499e1ecd151593c03fe5a50d512 + languageName: node + linkType: hard + "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3"