From 7b842b1449cbb7ccebc5ff091a70b4311c6856e4 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:34:24 +1300 Subject: [PATCH 01/14] Add core analytics package, with initialization event --- packages/analytics/CHANGELOG.md | 17 ++ packages/analytics/LICENSE.APACHE2 | 201 ++++++++++++++++++ packages/analytics/LICENSE.MIT0 | 16 ++ packages/analytics/README.md | 13 ++ packages/analytics/eslint.config.mjs | 15 ++ packages/analytics/package.json | 72 +++++++ packages/analytics/src/analytics.test.ts | 176 +++++++++++++++ packages/analytics/src/environment.test.ts | 57 +++++ packages/analytics/src/environment.ts | 135 ++++++++++++ packages/analytics/src/index.ts | 123 +++++++++++ packages/analytics/src/schema.ts | 85 ++++++++ packages/analytics/src/sender.test.ts | 101 +++++++++ packages/analytics/src/sender.ts | 78 +++++++ packages/analytics/tsconfig.json | 8 + packages/analytics/tsup.config.ts | 12 ++ packages/analytics/vitest.config.ts | 8 + packages/smart-accounts-kit/package.json | 1 + .../smart-accounts-kit/src/actions/index.ts | 3 + packages/smart-accounts-kit/src/analytics.ts | 58 +++++ .../smart-accounts-kit/src/contracts/index.ts | 3 + .../src/experimental/index.ts | 4 + packages/smart-accounts-kit/src/index.ts | 4 + .../smart-accounts-kit/src/utils/index.ts | 4 + packages/smart-accounts-kit/tsup.config.ts | 4 +- packages/smart-accounts-kit/vitest.config.ts | 4 + yarn.lock | 117 ++++++++++ 26 files changed, 1318 insertions(+), 1 deletion(-) create mode 100644 packages/analytics/CHANGELOG.md create mode 100644 packages/analytics/LICENSE.APACHE2 create mode 100644 packages/analytics/LICENSE.MIT0 create mode 100644 packages/analytics/README.md create mode 100644 packages/analytics/eslint.config.mjs create mode 100644 packages/analytics/package.json create mode 100644 packages/analytics/src/analytics.test.ts create mode 100644 packages/analytics/src/environment.test.ts create mode 100644 packages/analytics/src/environment.ts create mode 100644 packages/analytics/src/index.ts create mode 100644 packages/analytics/src/schema.ts create mode 100644 packages/analytics/src/sender.test.ts create mode 100644 packages/analytics/src/sender.ts create mode 100644 packages/analytics/tsconfig.json create mode 100644 packages/analytics/tsup.config.ts create mode 100644 packages/analytics/vitest.config.ts create mode 100644 packages/smart-accounts-kit/src/analytics.ts 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..c459a7e9 --- /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`. + +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..acc62f17 --- /dev/null +++ b/packages/analytics/package.json @@ -0,0 +1,72 @@ +{ + "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" + }, + "dependencies": { + "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", + "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..8b80be14 --- /dev/null +++ b/packages/analytics/src/analytics.test.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import nock from 'nock'; +/* eslint-disable-next-line id-length */ +import * as t from 'vitest'; + +import { Analytics } from '.'; +import { + getInitializationContext, + resetAnalyticsSessionForTests, +} from './environment'; +import type { AnalyticsEventV2 } from './schema'; + +t.describe('Analytics', () => { + let analytics: Analytics; + + t.beforeEach(() => { + resetAnalyticsSessionForTests(); + }); + + t.afterAll(() => { + /* eslint-disable-next-line import-x/no-named-as-default-member */ + nock.cleanAll(); + }); + + t.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)); + + t.expect(captured).toEqual([]); + + scope.done(); + }); + + t.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)); + + t.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(); + }); + + t.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)); + + t.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(); + }, + ); + + t.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)); + + t.expect(captured[0]?.properties?.sdk_version).toBe('3.0.0'); + + scope.done(); + }, + ); + + t.it('throws when session was never started', () => { + resetAnalyticsSessionForTests(); + analytics = new Analytics('http://127.0.0.5'); + analytics.enable(); + t.expect(() => analytics.trackInitialized()).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..16e14135 --- /dev/null +++ b/packages/analytics/src/environment.test.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/naming-convention -- analytics payload field names */ +/* eslint-disable-next-line id-length */ +import * as t from 'vitest'; + +import { + getInitializationContext, + getSessionBaseProperties, + resetAnalyticsSessionForTests, +} from './environment'; + +t.describe('analytics session (environment)', () => { + t.beforeEach(() => { + resetAnalyticsSessionForTests(); + }); + + t.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', + }); + t.expect(ctx).toEqual({ + sdk_version: '9.9.9', + anon_id: '11111111-2222-4333-8444-555555555555', + platform: 'nodejs', + }); + }, + ); + + t.it('generates anon_id when omitted on first call only', () => { + const first = getInitializationContext({ sdk_version: '1.0.0' }); + t.expect(first.sdk_version).toBe('1.0.0'); + t.expect(first.platform).toBe('nodejs'); + t.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' }); + t.expect(second.anon_id).toBe(first.anon_id); + t.expect(second.sdk_version).toBe('2.0.0'); + }); + + t.it('getSessionBaseProperties throws before session start', () => { + t.expect(() => getSessionBaseProperties()).toThrow( + /getInitializationContext/iu, + ); + }); + + t.it('getSessionBaseProperties returns copy after init', () => { + getInitializationContext({ sdk_version: '0.1.0' }); + const a = getSessionBaseProperties(); + const b = getSessionBaseProperties(); + t.expect(a).toEqual(b); + t.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..db8ddcbc --- /dev/null +++ b/packages/analytics/src/index.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-restricted-syntax */ +import createClient from 'openapi-fetch'; + +import { + getSessionBaseProperties, + mergeSessionProperties, +} from './environment'; +import type { + AnalyticsEventV2, + 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 + ); +} + +export class Analytics { + private enabled = false; + + private readonly sender: Sender; + + constructor(baseUrl: string) { + const client = createClient({ baseUrl }); + + const sendFn = async (batch: AnalyticsEventV2[]): Promise => { + const res = await client.POST('/v2/events', { body: batch }); + if (res.response.status !== 200) { + throw new Error(String(res.error)); + } + }; + + this.sender = new Sender({ batchSize: 100, baseTimeoutMs: 200, 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 (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); + } +} + +/** 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, + 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..5db9c24d --- /dev/null +++ b/packages/analytics/src/schema.ts @@ -0,0 +1,85 @@ +/* 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; +}; + +export type AnalyticsEventV2 = SmartAccountsKitPayload; + +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..da7b8b15 --- /dev/null +++ b/packages/analytics/src/sender.test.ts @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable-next-line id-length */ +import * as t from 'vitest'; + +import Sender from './sender'; + +t.describe('Sender', () => { + let sendFn: t.Mock<() => Promise>; + let sender: Sender; + + t.beforeEach(() => { + t.vi.useFakeTimers(); + sendFn = t.vi.fn().mockResolvedValue(undefined); + sender = new Sender({ batchSize: 2, baseTimeoutMs: 50, sendFn }); + }); + + t.afterEach(() => { + t.vi.useRealTimers(); + t.vi.clearAllMocks(); + }); + + t.it('should flush after timeout', async () => { + sender.enqueue('event1'); + await t.vi.advanceTimersByTimeAsync(50); + t.expect(sendFn).toHaveBeenCalledWith(['event1']); + }); + + t.it('should flush twice with correct batch size', async () => { + sender.enqueue('event1'); + sender.enqueue('event2'); + sender.enqueue('event3'); + t.expect(sendFn).toHaveBeenCalledTimes(0); + + await t.vi.advanceTimersByTimeAsync(50); + t.expect(sendFn).toHaveBeenCalledTimes(1); + t.expect(sendFn).toHaveBeenCalledWith(['event1', 'event2']); + + await t.vi.advanceTimersByTimeAsync(50); + t.expect(sendFn).toHaveBeenCalledTimes(2); + t.expect(sendFn).toHaveBeenCalledWith(['event3']); + }); + + t.it( + 'should handle failure (with exponential backoff) and reset base timeout after successful send', + async () => { + let shouldSendFail = true; + sendFn = t.vi.fn().mockImplementation(async () => { + if (shouldSendFail) { + return Promise.reject(new Error('Failed')); + } + return Promise.resolve(); + }); + sender = new Sender({ batchSize: 100, baseTimeoutMs: 50, sendFn }); + + shouldSendFail = true; + + sender.enqueue('event1'); + t.expect(sendFn).toHaveBeenCalledTimes(0); + + await t.vi.advanceTimersByTimeAsync(50); + t.expect(sendFn).toHaveBeenCalledTimes(1); + t.expect(sendFn).toHaveBeenCalledWith(['event1']); + + shouldSendFail = false; + + await t.vi.advanceTimersByTimeAsync(50); + t.expect(sendFn).toHaveBeenCalledTimes(1); + await t.vi.advanceTimersByTimeAsync(50); + t.expect(sendFn).toHaveBeenCalledTimes(2); + t.expect(sendFn).toHaveBeenCalledWith(['event1']); + + sender.enqueue('event2'); + await t.vi.advanceTimersByTimeAsync(50); + t.expect(sendFn).toHaveBeenCalledTimes(3); + t.expect(sendFn).toHaveBeenCalledWith(['event2']); + }, + ); + + t.it('should handle concurrent sends properly', async () => { + let resolveSend!: (value?: unknown) => void; + sendFn = t.vi.fn().mockImplementation(async () => { + await new Promise((resolve) => { + resolveSend = resolve; + }); + }); + sender = new Sender({ batchSize: 100, baseTimeoutMs: 1000, sendFn }); + + sender.enqueue('event1'); + await t.vi.advanceTimersByTimeAsync(1000); + t.expect(sendFn).toHaveBeenCalledWith(['event1']); + t.expect(sendFn).toHaveBeenCalledTimes(1); + + sender.enqueue('event2'); + resolveSend(); + await Promise.resolve(); + + await t.vi.advanceTimersByTimeAsync(1000); + t.expect(sendFn).toHaveBeenCalledWith(['event2']); + t.expect(sendFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/analytics/src/sender.ts b/packages/analytics/src/sender.ts new file mode 100644 index 00000000..f575e539 --- /dev/null +++ b/packages/analytics/src/sender.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-restricted-syntax */ +type SenderOptions = { + batchSize: number; + baseTimeoutMs: 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 { + private readonly sendFn: (batch: T[]) => Promise; + + private batch: T[] = []; + + private readonly batchSize: number; + + private readonly baseTimeoutMs: number; + + private currentTimeoutMs: number; + + private readonly maxTimeoutMs: number = 30_000; + + private timeoutId: ReturnType | null = null; + + private isSending: boolean = false; + + constructor(options: SenderOptions) { + this.batchSize = options.batchSize; + this.baseTimeoutMs = options.baseTimeoutMs; + this.currentTimeoutMs = options.baseTimeoutMs; + this.sendFn = options.sendFn; + } + + public enqueue(item: T): void { + this.batch.push(item); + this.schedule(); + } + + private schedule(): void { + 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.currentTimeoutMs); + } + } + + private async flush(): Promise { + if (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.currentTimeoutMs = this.baseTimeoutMs; + } catch (error) { + console.error('Sender: Failed to send batch', error); + this.batch = [...current, ...this.batch]; + this.currentTimeoutMs = Math.min( + this.currentTimeoutMs * 2, + this.maxTimeoutMs, + ); + } finally { + this.isSending = false; + this.schedule(); + } + } +} + +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/package.json b/packages/smart-accounts-kit/package.json index 3c04bfe9..b58ba393 100644 --- a/packages/smart-accounts-kit/package.json +++ b/packages/smart-accounts-kit/package.json @@ -138,6 +138,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/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/analytics.ts b/packages/smart-accounts-kit/src/analytics.ts new file mode 100644 index 00000000..c7709771 --- /dev/null +++ b/packages/smart-accounts-kit/src/analytics.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/naming-convention -- process.env */ +/* eslint-disable camelcase -- sdk_version matches analytics event payload keys */ +import { + Analytics, + METAMASK_ANALYTICS_ENDPOINT, + getInitializationContext, +} 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 { + let dntIndicator: string | undefined; + /* eslint-disable no-restricted-globals */ + if (typeof window === 'undefined') { + dntIndicator = process?.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); + +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; + } + + getInitializationContext({ sdk_version }); + + analytics.enable(); + analytics.trackInitialized(); +} 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/experimental/index.ts b/packages/smart-accounts-kit/src/experimental/index.ts index a69cbd5a..71dd5173 100644 --- a/packages/smart-accounts-kit/src/experimental/index.ts +++ b/packages/smart-accounts-kit/src/experimental/index.ts @@ -1,3 +1,7 @@ +import { ensureSmartAccountsKitAnalyticsBootstrapped } from '../analytics'; + +ensureSmartAccountsKitAnalyticsBootstrapped(); + export { DelegationStorageClient, type DelegationStoreFilter, 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/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 b1f39611..0efa4818 100644 --- a/yarn.lock +++ b/yarn.lock @@ -770,6 +770,22 @@ __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" + 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 +800,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" @@ -856,6 +873,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 +1189,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" @@ -3971,6 +4026,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" @@ -4143,6 +4205,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" @@ -4618,6 +4687,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" @@ -4847,6 +4927,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" @@ -4861,6 +4957,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" @@ -5150,6 +5253,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" @@ -5683,6 +5793,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" From 5dbc689ee7b66d3008932ed9e9fa313dcd8759e7 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:09:04 +1300 Subject: [PATCH 02/14] Add smart_accounts_kit_function_called event - fix openapi-fetch dependency issue - export DelegationStorageEnvironment --- packages/analytics/README.md | 2 +- packages/analytics/package.json | 3 +- packages/analytics/src/analytics.test.ts | 49 +++++++++++++++++ packages/analytics/src/index.ts | 53 ++++++++++++++++++- packages/analytics/src/schema.ts | 24 ++++++++- packages/smart-accounts-kit/README.md | 6 ++- packages/smart-accounts-kit/package.json | 2 + .../src/actions/caveatEnforcerClient.ts | 5 ++ .../actions/erc7710RedeemDelegationAction.ts | 11 ++++ ...715GetGrantedExecutionPermissionsAction.ts | 8 +++ ...5GetSupportedExecutionPermissionsAction.ts | 8 +++ ...rc7715RequestExecutionPermissionsAction.ts | 8 +++ .../src/actions/getCaveatAvailableAmount.ts | 28 ++++++++++ .../src/actions/infuraBundlerClient.ts | 5 ++ packages/smart-accounts-kit/src/analytics.ts | 15 ++++++ packages/smart-accounts-kit/src/caveats.ts | 12 +++-- packages/smart-accounts-kit/src/delegation.ts | 11 ++++ packages/smart-accounts-kit/src/executions.ts | 12 +++-- .../src/experimental/delegationStorage.ts | 40 ++++++++++++++ .../src/experimental/index.ts | 1 + packages/smart-accounts-kit/src/signatures.ts | 6 +++ .../src/toMetaMaskSmartAccount.ts | 8 +++ yarn.lock | 4 ++ 23 files changed, 305 insertions(+), 16 deletions(-) diff --git a/packages/analytics/README.md b/packages/analytics/README.md index c459a7e9..9c66582b 100644 --- a/packages/analytics/README.md +++ b/packages/analytics/README.md @@ -4,7 +4,7 @@ ### 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`. +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`**. diff --git a/packages/analytics/package.json b/packages/analytics/package.json index acc62f17..2ce85adc 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -56,7 +56,7 @@ "lint:eslint": "eslint . --cache --ext js,ts", "lint:fix": "yarn lint:eslint --fix" }, - "dependencies": { + "peerDependencies": { "openapi-fetch": "^0.13.5" }, "devDependencies": { @@ -64,6 +64,7 @@ "@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", diff --git a/packages/analytics/src/analytics.test.ts b/packages/analytics/src/analytics.test.ts index 8b80be14..98b1dcf4 100644 --- a/packages/analytics/src/analytics.test.ts +++ b/packages/analytics/src/analytics.test.ts @@ -173,4 +173,53 @@ t.describe('Analytics', () => { /getInitializationContext/iu, ); }); + + t.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)); + + t.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(); + }); + + t.it('throws when trackSdkFunctionCall session was never started', () => { + resetAnalyticsSessionForTests(); + analytics = new Analytics('http://127.0.0.7'); + analytics.enable(); + t.expect(() => analytics.trackSdkFunctionCall('x')).toThrow( + /getInitializationContext/iu, + ); + }); }); diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index db8ddcbc..11b141b7 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -8,6 +8,9 @@ import { } from './environment'; import type { AnalyticsEventV2, + SmartAccountsKitFunctionCallParameters, + SmartAccountsKitFunctionCallPayload, + SmartAccountsKitFunctionCallProperties, SmartAccountsKitBaseProperties, paths, } from './schema'; @@ -87,7 +90,7 @@ export class Analytics { if (!isCompleteBase(merged)) { throw new Error( - 'Analytics: trackInitialized produced incomplete base (ensure getInitializationContext ran and sdk_version, anon_id, platform are set)', + 'Analytics: trackInitialized produced incomplete base configuration (ensure getInitializationContext ran and sdk_version, anon_id, platform are set)', ); } @@ -101,6 +104,51 @@ export class Analytics { 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. */ @@ -117,6 +165,9 @@ export { } from './environment'; export type { AnalyticsEventV2, + SmartAccountsKitFunctionCallParameters, + SmartAccountsKitFunctionCallPayload, + SmartAccountsKitFunctionCallProperties, SmartAccountsKitBaseProperties, SmartAccountsKitInitializedProperties, SmartAccountsKitPayload, diff --git a/packages/analytics/src/schema.ts b/packages/analytics/src/schema.ts index 5db9c24d..42512deb 100644 --- a/packages/analytics/src/schema.ts +++ b/packages/analytics/src/schema.ts @@ -39,7 +39,29 @@ export type SmartAccountsKitPayload = { properties: SmartAccountsKitBaseProperties; }; -export type AnalyticsEventV2 = SmartAccountsKitPayload; +/** Non-sensitive primitive fields only; callers must not pass secrets or PII. */ +export type SmartAccountsKitFunctionCallParameters = Record< + string, + unknown +>; + +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': { diff --git a/packages/smart-accounts-kit/README.md b/packages/smart-accounts-kit/README.md index c8df5c71..48b847cf 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 openapi-fetch ``` Npm: ```sh -npm install @metamask/smart-accounts-kit +npm install @metamask/smart-accounts-kit viem openapi-fetch ``` +`viem` and [`openapi-fetch`](https://github.com/drwpow/openapi-typescript/tree/main/packages/openapi-fetch) are peer dependencies; install compatible versions alongside the kit. + ## Overview --- diff --git a/packages/smart-accounts-kit/package.json b/packages/smart-accounts-kit/package.json index b58ba393..670b67b8 100644 --- a/packages/smart-accounts-kit/package.json +++ b/packages/smart-accounts-kit/package.json @@ -151,6 +151,7 @@ "eslint-plugin-n": "^17.10.3", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-promise": "^7.1.0", + "openapi-fetch": "^0.13.5", "prettier": "^3.5.3", "sinon": "^18.0.0", "ts-node": "^10.9.2", @@ -163,6 +164,7 @@ "vitest": "^3.2.4" }, "peerDependencies": { + "openapi-fetch": "^0.13.5", "viem": "^2.31.4" } } diff --git a/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts b/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts index 3a974f67..b087002a 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..77631cc2 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..88055f1a 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..54d4fc96 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,9 @@ 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 +208,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 +251,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 +293,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/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 index c7709771..3ed0bfe6 100644 --- a/packages/smart-accounts-kit/src/analytics.ts +++ b/packages/smart-accounts-kit/src/analytics.ts @@ -4,6 +4,7 @@ import { Analytics, METAMASK_ANALYTICS_ENDPOINT, getInitializationContext, + type SmartAccountsKitFunctionCallParameters, } from '@metamask/smart-accounts-kit-analytics'; import { version as sdk_version } from '../package.json'; @@ -31,6 +32,20 @@ function isAnalyticsDisabled(): boolean { 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 { + analytics.trackSdkFunctionCall(functionName, parameters); +} + let hasBootstrapped = false; /** 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/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index 4349b36f..445ede17 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'; @@ -256,6 +257,11 @@ export const resolveAuthority = (parentDelegation?: Delegation | Hex): Hex => { export const createDelegation = ( options: CreateDelegationOptions, ): Delegation => { + trackSmartAccountsKitFunctionCall('createDelegation', { + hasCaveats: options.caveats !== undefined, + hasParentDelegation: options.parentDelegation !== undefined, + }); + return { delegate: options.to, delegator: options.from, @@ -275,6 +281,11 @@ export const createDelegation = ( export const createOpenDelegation = ( options: CreateOpenDelegationOptions, ): Delegation => { + trackSmartAccountsKitFunctionCall('createOpenDelegation', { + hasCaveats: options.caveats !== undefined, + hasParentDelegation: options.parentDelegation !== undefined, + }); + return { delegate: ANY_BENEFICIARY, delegator: options.from, 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..c8bfb6fe 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,24 @@ 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' { + for (const [key, env] of Object.entries(DelegationStorageEnvironment) as [keyof typeof DelegationStorageEnvironment, Environment][]) { + if (env.apiUrl === apiUrl) { + return key; + } + } + return "custom"; +} + + + export type Environment = { apiUrl: string; }; @@ -55,6 +74,10 @@ 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 +122,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 +172,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 +203,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 71dd5173..b2ad30bd 100644 --- a/packages/smart-accounts-kit/src/experimental/index.ts +++ b/packages/smart-accounts-kit/src/experimental/index.ts @@ -4,6 +4,7 @@ ensureSmartAccountsKitAnalyticsBootstrapped(); export { DelegationStorageClient, + DelegationStorageEnvironment, type DelegationStoreFilter, type Environment, type DelegationStorageConfig, diff --git a/packages/smart-accounts-kit/src/signatures.ts b/packages/smart-accounts-kit/src/signatures.ts index 2ece2ec4..eb223b79 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/yarn.lock b/yarn.lock index 0efa4818..87c2118b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -783,6 +783,8 @@ __metadata: 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 @@ -814,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" prettier: "npm:^3.5.3" sinon: "npm:^18.0.0" ts-node: "npm:^10.9.2" @@ -826,6 +829,7 @@ __metadata: vitest: "npm:^3.2.4" webauthn-p256: "npm:^0.0.10" peerDependencies: + openapi-fetch: ^0.13.5 viem: ^2.31.4 languageName: unknown linkType: soft From c2a428a1ea88e271d3290dee2c39702deb92db3e Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:26:25 +1300 Subject: [PATCH 03/14] Fix linting --- packages/analytics/src/schema.ts | 5 +---- .../src/actions/caveatEnforcerClient.ts | 2 +- ...715GetGrantedExecutionPermissionsAction.ts | 2 +- ...5GetSupportedExecutionPermissionsAction.ts | 2 +- .../src/actions/getCaveatAvailableAmount.ts | 9 +++++--- .../src/experimental/delegationStorage.ts | 22 ++++++++++++------- packages/smart-accounts-kit/src/signatures.ts | 2 +- 7 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/analytics/src/schema.ts b/packages/analytics/src/schema.ts index 42512deb..1dbd9226 100644 --- a/packages/analytics/src/schema.ts +++ b/packages/analytics/src/schema.ts @@ -40,10 +40,7 @@ export type SmartAccountsKitPayload = { }; /** Non-sensitive primitive fields only; callers must not pass secrets or PII. */ -export type SmartAccountsKitFunctionCallParameters = Record< - string, - unknown ->; +export type SmartAccountsKitFunctionCallParameters = Record; export type SmartAccountsKitFunctionCallProperties = SmartAccountsKitBaseProperties & { diff --git a/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts b/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts index b087002a..1186ff50 100644 --- a/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts +++ b/packages/smart-accounts-kit/src/actions/caveatEnforcerClient.ts @@ -41,7 +41,7 @@ export function createCaveatEnforcerClient< trackSmartAccountsKitFunctionCall('createCaveatEnforcerClient', { chainId: client.chain?.id ?? null, }); - + return client.extend(caveatEnforcerActions({ environment })); } diff --git a/packages/smart-accounts-kit/src/actions/erc7715GetGrantedExecutionPermissionsAction.ts b/packages/smart-accounts-kit/src/actions/erc7715GetGrantedExecutionPermissionsAction.ts index 77631cc2..a1e8c9ad 100644 --- a/packages/smart-accounts-kit/src/actions/erc7715GetGrantedExecutionPermissionsAction.ts +++ b/packages/smart-accounts-kit/src/actions/erc7715GetGrantedExecutionPermissionsAction.ts @@ -31,7 +31,7 @@ export async function 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 88055f1a..7bf354bb 100644 --- a/packages/smart-accounts-kit/src/actions/erc7715GetSupportedExecutionPermissionsAction.ts +++ b/packages/smart-accounts-kit/src/actions/erc7715GetSupportedExecutionPermissionsAction.ts @@ -40,7 +40,7 @@ export async function erc7715GetSupportedExecutionPermissionsAction( chainId: client.chain?.id ?? null, }, ); - + const result = await client.request( { method: 'wallet_getSupportedExecutionPermissions', diff --git a/packages/smart-accounts-kit/src/actions/getCaveatAvailableAmount.ts b/packages/smart-accounts-kit/src/actions/getCaveatAvailableAmount.ts index 54d4fc96..4f55bb35 100644 --- a/packages/smart-accounts-kit/src/actions/getCaveatAvailableAmount.ts +++ b/packages/smart-accounts-kit/src/actions/getCaveatAvailableAmount.ts @@ -169,9 +169,12 @@ export async function getErc20StreamingEnforcerAvailableAmount( environment: SmartAccountsEnvironment, params: CaveatEnforcerParams, ): Promise { - trackSmartAccountsKitFunctionCall('getErc20StreamingEnforcerAvailableAmount', { - chainId: client.chain?.id ?? null, - }); + trackSmartAccountsKitFunctionCall( + 'getErc20StreamingEnforcerAvailableAmount', + { + chainId: client.chain?.id ?? null, + }, + ); const enforcerName = 'ERC20StreamingEnforcer'; const delegationManager = getDelegationManager(environment); const enforcerAddress = getEnforcerAddress({ diff --git a/packages/smart-accounts-kit/src/experimental/delegationStorage.ts b/packages/smart-accounts-kit/src/experimental/delegationStorage.ts index c8bfb6fe..5c089cde 100644 --- a/packages/smart-accounts-kit/src/experimental/delegationStorage.ts +++ b/packages/smart-accounts-kit/src/experimental/delegationStorage.ts @@ -40,17 +40,20 @@ export const DelegationStorageEnvironment: { * @param apiUrl - The API URL to check. * @returns The environment key ('dev' or 'prod'), or 'custom'. */ -function getDelegationStorageEnvironment(apiUrl: string): 'dev' | 'prod' | 'custom' { - for (const [key, env] of Object.entries(DelegationStorageEnvironment) as [keyof typeof DelegationStorageEnvironment, Environment][]) { +function getDelegationStorageEnvironment( + apiUrl: string, +): 'dev' | 'prod' | 'custom' { + for (const [key, env] of Object.entries(DelegationStorageEnvironment) as [ + keyof typeof DelegationStorageEnvironment, + Environment, + ][]) { if (env.apiUrl === apiUrl) { return key; } } - return "custom"; + return 'custom'; } - - export type Environment = { apiUrl: string; }; @@ -74,9 +77,12 @@ export class DelegationStorageClient { constructor(config: DelegationStorageConfig) { const { apiUrl } = config.environment; - trackSmartAccountsKitFunctionCall('experimental.DelegationStorageClient.constructor', { - environment: getDelegationStorageEnvironment(apiUrl) - }); + trackSmartAccountsKitFunctionCall( + 'experimental.DelegationStorageClient.constructor', + { + environment: getDelegationStorageEnvironment(apiUrl), + }, + ); if (apiUrl.endsWith(this.#apiVersionPrefix)) { this.#apiUrl = apiUrl; diff --git a/packages/smart-accounts-kit/src/signatures.ts b/packages/smart-accounts-kit/src/signatures.ts index eb223b79..815e3a0b 100644 --- a/packages/smart-accounts-kit/src/signatures.ts +++ b/packages/smart-accounts-kit/src/signatures.ts @@ -31,7 +31,7 @@ export const aggregateSignature = ({ trackSmartAccountsKitFunctionCall('aggregateSignature', { signatureCount: signatures.length, }); - + if (signatures.length === 0) { return '0x'; } From bef494e0450251a15f88790b79e690c518db1560 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:25:25 +1300 Subject: [PATCH 04/14] Simplify getDelegationStorageEnvironment function --- .../src/experimental/delegationStorage.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/smart-accounts-kit/src/experimental/delegationStorage.ts b/packages/smart-accounts-kit/src/experimental/delegationStorage.ts index 5c089cde..7736fc89 100644 --- a/packages/smart-accounts-kit/src/experimental/delegationStorage.ts +++ b/packages/smart-accounts-kit/src/experimental/delegationStorage.ts @@ -43,15 +43,11 @@ export const DelegationStorageEnvironment: { function getDelegationStorageEnvironment( apiUrl: string, ): 'dev' | 'prod' | 'custom' { - for (const [key, env] of Object.entries(DelegationStorageEnvironment) as [ - keyof typeof DelegationStorageEnvironment, - Environment, - ][]) { - if (env.apiUrl === apiUrl) { - return key; - } - } - return '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 = { From a9abd52bce070d1ff4ada80dc9c4a6bca109c785 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:01:59 +1300 Subject: [PATCH 05/14] After hitting maximumFailureCount, disable the sender to avoid infinite retries and bloating re-enqueues --- packages/analytics/src/index.ts | 7 ++++- packages/analytics/src/sender.test.ts | 45 +++++++++++++++++++++++++-- packages/analytics/src/sender.ts | 44 ++++++++++++++++++++------ 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 11b141b7..0b0c669b 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -48,7 +48,12 @@ export class Analytics { } }; - this.sender = new Sender({ batchSize: 100, baseTimeoutMs: 200, sendFn }); + this.sender = new Sender({ + batchSize: 100, + baseTimeoutMs: 200, + maxFailureCount: 10, + sendFn, + }); } public enable(): void { diff --git a/packages/analytics/src/sender.test.ts b/packages/analytics/src/sender.test.ts index da7b8b15..ed299165 100644 --- a/packages/analytics/src/sender.test.ts +++ b/packages/analytics/src/sender.test.ts @@ -11,7 +11,12 @@ t.describe('Sender', () => { t.beforeEach(() => { t.vi.useFakeTimers(); sendFn = t.vi.fn().mockResolvedValue(undefined); - sender = new Sender({ batchSize: 2, baseTimeoutMs: 50, sendFn }); + sender = new Sender({ + batchSize: 2, + baseTimeoutMs: 50, + maxFailureCount: 10, + sendFn, + }); }); t.afterEach(() => { @@ -50,7 +55,12 @@ t.describe('Sender', () => { } return Promise.resolve(); }); - sender = new Sender({ batchSize: 100, baseTimeoutMs: 50, sendFn }); + sender = new Sender({ + batchSize: 100, + baseTimeoutMs: 50, + maxFailureCount: 100, + sendFn, + }); shouldSendFail = true; @@ -76,6 +86,30 @@ t.describe('Sender', () => { }, ); + t.it( + 'disables and purges when maxFailureCount is reached; enqueue is noop', + async () => { + sendFn = t.vi.fn().mockRejectedValue(new Error('Failed')); + sender = new Sender({ + batchSize: 100, + baseTimeoutMs: 50, + maxFailureCount: 2, + sendFn, + }); + + sender.enqueue('event1'); + await t.vi.advanceTimersByTimeAsync(50); + t.expect(sendFn).toHaveBeenCalledTimes(1); + + await t.vi.advanceTimersByTimeAsync(100); + t.expect(sendFn).toHaveBeenCalledTimes(2); + + sender.enqueue('event2'); + await t.vi.advanceTimersByTimeAsync(60_000); + t.expect(sendFn).toHaveBeenCalledTimes(2); + }, + ); + t.it('should handle concurrent sends properly', async () => { let resolveSend!: (value?: unknown) => void; sendFn = t.vi.fn().mockImplementation(async () => { @@ -83,7 +117,12 @@ t.describe('Sender', () => { resolveSend = resolve; }); }); - sender = new Sender({ batchSize: 100, baseTimeoutMs: 1000, sendFn }); + sender = new Sender({ + batchSize: 100, + baseTimeoutMs: 1000, + maxFailureCount: 100, + sendFn, + }); sender.enqueue('event1'); await t.vi.advanceTimersByTimeAsync(1000); diff --git a/packages/analytics/src/sender.ts b/packages/analytics/src/sender.ts index f575e539..408b1698 100644 --- a/packages/analytics/src/sender.ts +++ b/packages/analytics/src/sender.ts @@ -3,6 +3,7 @@ type SenderOptions = { batchSize: number; baseTimeoutMs: number; + maxFailureCount: number; sendFn: (batch: T[]) => Promise; }; @@ -11,6 +12,8 @@ type SenderOptions = { * with exponential backoff on errors. */ class Sender { + #isDisabled = false; + private readonly sendFn: (batch: T[]) => Promise; private batch: T[] = []; @@ -19,7 +22,9 @@ class Sender { private readonly baseTimeoutMs: number; - private currentTimeoutMs: number; + private readonly maxFailureCount: number; + + private failureCount: number = 0; private readonly maxTimeoutMs: number = 30_000; @@ -30,27 +35,33 @@ class Sender { constructor(options: SenderOptions) { this.batchSize = options.batchSize; this.baseTimeoutMs = options.baseTimeoutMs; - this.currentTimeoutMs = options.baseTimeoutMs; + this.maxFailureCount = options.maxFailureCount; this.sendFn = options.sendFn; } public enqueue(item: T): void { + if (this.#isDisabled) { + return; + } this.batch.push(item); this.schedule(); } private 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.currentTimeoutMs); + }, this.getTimeoutMs()); } } private async flush(): Promise { - if (this.isSending || this.batch.length === 0) { + if (this.#isDisabled || this.isSending || this.batch.length === 0) { return; } @@ -60,19 +71,32 @@ class Sender { try { await this.sendFn(current); - this.currentTimeoutMs = this.baseTimeoutMs; + this.failureCount = 0; } catch (error) { console.error('Sender: Failed to send batch', error); - this.batch = [...current, ...this.batch]; - this.currentTimeoutMs = Math.min( - this.currentTimeoutMs * 2, - this.maxTimeoutMs, - ); + 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(); } } + + private getTimeoutMs(): number { + return Math.min( + this.baseTimeoutMs * 2 ** this.failureCount, + this.maxTimeoutMs, + ); + } } export default Sender; From d946bc48e7c2fbc3d5d698d6b1841888fe6904a2 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:15:14 +1300 Subject: [PATCH 06/14] Add try/catch blocks to top level analytics functions --- packages/smart-accounts-kit/src/analytics.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/smart-accounts-kit/src/analytics.ts b/packages/smart-accounts-kit/src/analytics.ts index 3ed0bfe6..df2ae970 100644 --- a/packages/smart-accounts-kit/src/analytics.ts +++ b/packages/smart-accounts-kit/src/analytics.ts @@ -43,7 +43,10 @@ export function trackSmartAccountsKitFunctionCall( functionName: string, parameters?: SmartAccountsKitFunctionCallParameters, ): void { - analytics.trackSdkFunctionCall(functionName, parameters); + try { + analytics.trackSdkFunctionCall(functionName, parameters); + // eslint-disable-next-line no-empty, @typescript-eslint/no-unused-vars + } catch (_error) {} } let hasBootstrapped = false; @@ -66,8 +69,10 @@ export function ensureSmartAccountsKitAnalyticsBootstrapped(): void { return; } - getInitializationContext({ sdk_version }); - - analytics.enable(); - analytics.trackInitialized(); + try { + getInitializationContext({ sdk_version }); + analytics.enable(); + analytics.trackInitialized(); + // eslint-disable-next-line no-empty, @typescript-eslint/no-unused-vars + } catch (_error) {} } From 4ee465dc3e33c26c537d20415b1155ee3fd14ccf Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:46:51 +1300 Subject: [PATCH 07/14] Update sideeffects to ensure top-level files are fully imported --- packages/smart-accounts-kit/package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/smart-accounts-kit/package.json b/packages/smart-accounts-kit/package.json index 670b67b8..80d138c4 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", @@ -167,4 +172,4 @@ "openapi-fetch": "^0.13.5", "viem": "^2.31.4" } -} +} \ No newline at end of file From 208dfa2b17de708cf2ca41a0bc2fddd761e7d00f Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:48:02 +1300 Subject: [PATCH 08/14] Don't log error when failing to send batch --- packages/analytics/src/sender.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/analytics/src/sender.ts b/packages/analytics/src/sender.ts index 408b1698..bee571e2 100644 --- a/packages/analytics/src/sender.ts +++ b/packages/analytics/src/sender.ts @@ -73,7 +73,6 @@ class Sender { await this.sendFn(current); this.failureCount = 0; } catch (error) { - console.error('Sender: Failed to send batch', error); this.failureCount += 1; if (this.failureCount >= this.maxFailureCount) { this.#isDisabled = true; From 5c9664218fcfb4597bff24b3db429d80e7521903 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:20:08 +1300 Subject: [PATCH 09/14] Fix up some oddities: - Smart Accounts Kit package now declares a dependency on openapi-fetch - 'error' parameter in catch clause is removed - Disable analytics in CI - Add typecheck for global process to --- packages/analytics/src/sender.ts | 2 +- packages/smart-accounts-kit/README.md | 6 +++--- packages/smart-accounts-kit/package.json | 5 ++--- packages/smart-accounts-kit/src/analytics.ts | 15 +++++++++++++-- yarn.lock | 1 - 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/analytics/src/sender.ts b/packages/analytics/src/sender.ts index bee571e2..4e399413 100644 --- a/packages/analytics/src/sender.ts +++ b/packages/analytics/src/sender.ts @@ -72,7 +72,7 @@ class Sender { try { await this.sendFn(current); this.failureCount = 0; - } catch (error) { + } catch { this.failureCount += 1; if (this.failureCount >= this.maxFailureCount) { this.#isDisabled = true; diff --git a/packages/smart-accounts-kit/README.md b/packages/smart-accounts-kit/README.md index 48b847cf..2a535b15 100644 --- a/packages/smart-accounts-kit/README.md +++ b/packages/smart-accounts-kit/README.md @@ -19,16 +19,16 @@ The MetaMask Smart Accounts Kit is a [Viem](https://viem.sh)-based collection of Yarn: ```sh -yarn add @metamask/smart-accounts-kit viem openapi-fetch +yarn add @metamask/smart-accounts-kit viem ``` Npm: ```sh -npm install @metamask/smart-accounts-kit viem openapi-fetch +npm install @metamask/smart-accounts-kit viem ``` -`viem` and [`openapi-fetch`](https://github.com/drwpow/openapi-typescript/tree/main/packages/openapi-fetch) are peer dependencies; install compatible versions alongside the kit. +`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 80d138c4..1dcb915d 100644 --- a/packages/smart-accounts-kit/package.json +++ b/packages/smart-accounts-kit/package.json @@ -134,6 +134,7 @@ "@metamask/delegation-core": "^0.4.0", "@metamask/delegation-deployments": "^0.17.0", "buffer": "^6.0.3", + "openapi-fetch": "^0.13.5", "webauthn-p256": "^0.0.10" }, "devDependencies": { @@ -156,7 +157,6 @@ "eslint-plugin-n": "^17.10.3", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-promise": "^7.1.0", - "openapi-fetch": "^0.13.5", "prettier": "^3.5.3", "sinon": "^18.0.0", "ts-node": "^10.9.2", @@ -169,7 +169,6 @@ "vitest": "^3.2.4" }, "peerDependencies": { - "openapi-fetch": "^0.13.5", "viem": "^2.31.4" } -} \ No newline at end of file +} diff --git a/packages/smart-accounts-kit/src/analytics.ts b/packages/smart-accounts-kit/src/analytics.ts index df2ae970..406ba6b5 100644 --- a/packages/smart-accounts-kit/src/analytics.ts +++ b/packages/smart-accounts-kit/src/analytics.ts @@ -15,10 +15,21 @@ import { version as sdk_version } from '../package.json'; * @returns True when DNT is enabled. */ function isAnalyticsDisabled(): boolean { - let dntIndicator: string | undefined; /* 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 = process?.env?.DO_NOT_TRACK; + dntIndicator = env?.DO_NOT_TRACK; } else { dntIndicator = navigator.doNotTrack ?? (window as { doNotTrack?: string }).doNotTrack; diff --git a/yarn.lock b/yarn.lock index 87c2118b..acded3e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -829,7 +829,6 @@ __metadata: vitest: "npm:^3.2.4" webauthn-p256: "npm:^0.0.10" peerDependencies: - openapi-fetch: ^0.13.5 viem: ^2.31.4 languageName: unknown linkType: soft From c82534b78cd45856e318c1b6cc037ca32272b559 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:42:10 +1300 Subject: [PATCH 10/14] Track resolved caveat names --- packages/smart-accounts-kit/src/delegation.ts | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index 445ede17..d654acf9 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -248,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. * @@ -257,16 +281,22 @@ export const resolveAuthority = (parentDelegation?: Delegation | Hex): Hex => { export const createDelegation = ( options: CreateDelegationOptions, ): Delegation => { + const caveats = resolveCaveats(options); + trackSmartAccountsKitFunctionCall('createDelegation', { - hasCaveats: options.caveats !== undefined, 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', }; @@ -281,16 +311,22 @@ export const createDelegation = ( export const createOpenDelegation = ( options: CreateOpenDelegationOptions, ): Delegation => { - trackSmartAccountsKitFunctionCall('createOpenDelegation', { - hasCaveats: options.caveats !== undefined, + const caveats = resolveCaveats(options); + + trackSmartAccountsKitFunctionCall('createDelegation', { 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', }; From 9ae28b153bf5fde3d1609daa547325813e13283b Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:02:38 +1300 Subject: [PATCH 11/14] Fix incorrect method name in event tracking for createOpenDelegation --- packages/smart-accounts-kit/src/delegation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smart-accounts-kit/src/delegation.ts b/packages/smart-accounts-kit/src/delegation.ts index d654acf9..0de719e7 100644 --- a/packages/smart-accounts-kit/src/delegation.ts +++ b/packages/smart-accounts-kit/src/delegation.ts @@ -313,7 +313,7 @@ export const createOpenDelegation = ( ): Delegation => { const caveats = resolveCaveats(options); - trackSmartAccountsKitFunctionCall('createDelegation', { + trackSmartAccountsKitFunctionCall('createOpenDelegation', { hasParentDelegation: options.parentDelegation !== undefined, scope: options.scope, caveatNames: getCaveatNames({ From 68596829d0b25ebebd6622a765533390891a1575 Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:59:17 +1300 Subject: [PATCH 12/14] Fix issue where bigints fail to serialize, causing analytics to stall --- packages/analytics/src/index.ts | 34 ++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 0b0c669b..26eb1f32 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -33,6 +33,36 @@ function isCompleteBase( ); } +/** + * 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; @@ -42,7 +72,9 @@ export class Analytics { const client = createClient({ baseUrl }); const sendFn = async (batch: AnalyticsEventV2[]): Promise => { - const res = await client.POST('/v2/events', { body: batch }); + const normalisedBatch = normalise(batch); + + const res = await client.POST('/v2/events', { body: normalisedBatch }); if (res.response.status !== 200) { throw new Error(String(res.error)); } From 140184c397cabb4763ffc0edfa94b00ecaa1885a Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:01:33 +1300 Subject: [PATCH 13/14] Minor improvements to Sender: - maxTimeoutMs is now configurable - private members are now all # prefixed --- packages/analytics/src/index.ts | 6 +- packages/analytics/src/sender.test.ts | 4 ++ packages/analytics/src/sender.ts | 86 ++++++++++++++------------- 3 files changed, 52 insertions(+), 44 deletions(-) diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 26eb1f32..b987bc05 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -80,10 +80,12 @@ export class Analytics { } }; + // timeout maximums are kept low, to avoid failing analytics requests keeping the process alive for too long this.sender = new Sender({ batchSize: 100, - baseTimeoutMs: 200, - maxFailureCount: 10, + baseTimeoutMs: 100, + maxFailureCount: 3, + maxTimeoutMs: 500, sendFn, }); } diff --git a/packages/analytics/src/sender.test.ts b/packages/analytics/src/sender.test.ts index ed299165..d54fb8ce 100644 --- a/packages/analytics/src/sender.test.ts +++ b/packages/analytics/src/sender.test.ts @@ -15,6 +15,7 @@ t.describe('Sender', () => { batchSize: 2, baseTimeoutMs: 50, maxFailureCount: 10, + maxTimeoutMs: 30_000, sendFn, }); }); @@ -59,6 +60,7 @@ t.describe('Sender', () => { batchSize: 100, baseTimeoutMs: 50, maxFailureCount: 100, + maxTimeoutMs: 30_000, sendFn, }); @@ -94,6 +96,7 @@ t.describe('Sender', () => { batchSize: 100, baseTimeoutMs: 50, maxFailureCount: 2, + maxTimeoutMs: 30_000, sendFn, }); @@ -121,6 +124,7 @@ t.describe('Sender', () => { batchSize: 100, baseTimeoutMs: 1000, maxFailureCount: 100, + maxTimeoutMs: 30_000, sendFn, }); diff --git a/packages/analytics/src/sender.ts b/packages/analytics/src/sender.ts index 4e399413..90ed2f08 100644 --- a/packages/analytics/src/sender.ts +++ b/packages/analytics/src/sender.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable no-restricted-syntax */ + type SenderOptions = { batchSize: number; baseTimeoutMs: number; maxFailureCount: number; + maxTimeoutMs: number; sendFn: (batch: T[]) => Promise; }; @@ -12,88 +13,89 @@ type SenderOptions = { * with exponential backoff on errors. */ class Sender { - #isDisabled = false; + readonly #sendFn: (batch: T[]) => Promise; - private readonly sendFn: (batch: T[]) => Promise; + readonly #batchSize: number; - private batch: T[] = []; + readonly #baseTimeoutMs: number; - private readonly batchSize: number; + readonly #maxFailureCount: number; - private readonly baseTimeoutMs: number; + readonly #maxTimeoutMs: number; - private readonly maxFailureCount: number; + #isDisabled = false; - private failureCount: number = 0; + #batch: T[] = []; - private readonly maxTimeoutMs: number = 30_000; + #failureCount: number = 0; - private timeoutId: ReturnType | null = null; + #timeoutId: ReturnType | null = null; - private isSending: boolean = false; + #isSending: boolean = false; constructor(options: SenderOptions) { - this.batchSize = options.batchSize; - this.baseTimeoutMs = options.baseTimeoutMs; - this.maxFailureCount = options.maxFailureCount; - this.sendFn = options.sendFn; + 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(); + this.#batch.push(item); + this.#schedule(); } - private schedule(): void { + #schedule(): void { if (this.#isDisabled) { return; } - if (this.batch.length > 0 && !this.timeoutId) { - this.timeoutId = setTimeout(() => { - this.timeoutId = null; + 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()); + this.#flush(); + }, this.#getTimeoutMs()); } } - private async flush(): Promise { - if (this.#isDisabled || this.isSending || this.batch.length === 0) { + 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); + 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; + await this.#sendFn(current); + this.#failureCount = 0; } catch { - this.failureCount += 1; - if (this.failureCount >= this.maxFailureCount) { + this.#failureCount += 1; + if (this.#failureCount >= this.#maxFailureCount) { this.#isDisabled = true; - this.batch = []; - if (this.timeoutId !== null) { - clearTimeout(this.timeoutId); - this.timeoutId = null; + this.#batch = []; + if (this.#timeoutId !== null) { + clearTimeout(this.#timeoutId); + this.#timeoutId = null; } } else { - this.batch = [...current, ...this.batch]; + this.#batch = [...current, ...this.#batch]; } } finally { - this.isSending = false; - this.schedule(); + this.#isSending = false; + this.#schedule(); } } - private getTimeoutMs(): number { + #getTimeoutMs(): number { return Math.min( - this.baseTimeoutMs * 2 ** this.failureCount, - this.maxTimeoutMs, + this.#baseTimeoutMs * 2 ** this.#failureCount, + this.#maxTimeoutMs, ); } } From 2a298b5fbcfb01b2445bea80942a05b65ec6de3a Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:04:52 +1300 Subject: [PATCH 14/14] Import individual exports from vitest --- packages/analytics/src/analytics.test.ts | 167 ++++++++++--------- packages/analytics/src/environment.test.ts | 54 +++---- packages/analytics/src/sender.test.ts | 177 ++++++++++----------- 3 files changed, 191 insertions(+), 207 deletions(-) diff --git a/packages/analytics/src/analytics.test.ts b/packages/analytics/src/analytics.test.ts index 98b1dcf4..5f470683 100644 --- a/packages/analytics/src/analytics.test.ts +++ b/packages/analytics/src/analytics.test.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import nock from 'nock'; -/* eslint-disable-next-line id-length */ -import * as t from 'vitest'; +import { describe, beforeEach, afterAll, it, expect } from 'vitest'; import { Analytics } from '.'; import { @@ -10,19 +9,19 @@ import { } from './environment'; import type { AnalyticsEventV2 } from './schema'; -t.describe('Analytics', () => { +describe('Analytics', () => { let analytics: Analytics; - t.beforeEach(() => { + beforeEach(() => { resetAnalyticsSessionForTests(); }); - t.afterAll(() => { + afterAll(() => { /* eslint-disable-next-line import-x/no-named-as-default-member */ nock.cleanAll(); }); - t.it('should do nothing when disabled', async () => { + it('should do nothing when disabled', async () => { let captured: AnalyticsEventV2[] = []; const scope = nock('http://127.0.0.1') .post('/v2/events', (body) => { @@ -45,12 +44,12 @@ t.describe('Analytics', () => { await new Promise((resolve) => setTimeout(resolve, 300)); - t.expect(captured).toEqual([]); + expect(captured).toEqual([]); scope.done(); }); - t.it('should track initialization when enabled', async () => { + it('should track initialization when enabled', async () => { let captured: AnalyticsEventV2[] = []; const scope = nock('http://127.0.0.2') .post('/v2/events', (body) => { @@ -76,7 +75,7 @@ t.describe('Analytics', () => { await new Promise((resolve) => setTimeout(resolve, 300)); - t.expect(captured).toEqual([ + expect(captured).toEqual([ { namespace: 'metamask/smart-accounts-kit', event_name: 'smart_accounts_kit_initialized', @@ -92,89 +91,83 @@ t.describe('Analytics', () => { scope.done(); }); - t.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)); - - t.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', - }, + 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(); - }, - ); - - t.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)); - - t.expect(captured[0]?.properties?.sdk_version).toBe('3.0.0'); - - scope.done(); - }, - ); - - t.it('throws when session was never started', () => { + }, + ]); + + 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(); - t.expect(() => analytics.trackInitialized()).toThrow( + expect(() => analytics.trackInitialized()).toThrow( /getInitializationContext/iu, ); }); - t.it('should track SDK function call when enabled', async () => { + it('should track SDK function call when enabled', async () => { let captured: AnalyticsEventV2[] = []; const scope = nock('http://127.0.0.6') .post('/v2/events', (body) => { @@ -197,7 +190,7 @@ t.describe('Analytics', () => { await new Promise((resolve) => setTimeout(resolve, 300)); - t.expect(captured).toEqual([ + expect(captured).toEqual([ { namespace: 'metamask/smart-accounts-kit', event_name: 'smart_accounts_kit_function_called', @@ -214,11 +207,11 @@ t.describe('Analytics', () => { scope.done(); }); - t.it('throws when trackSdkFunctionCall session was never started', () => { + it('throws when trackSdkFunctionCall session was never started', () => { resetAnalyticsSessionForTests(); analytics = new Analytics('http://127.0.0.7'); analytics.enable(); - t.expect(() => analytics.trackSdkFunctionCall('x')).toThrow( + expect(() => analytics.trackSdkFunctionCall('x')).toThrow( /getInitializationContext/iu, ); }); diff --git a/packages/analytics/src/environment.test.ts b/packages/analytics/src/environment.test.ts index 16e14135..18ea744a 100644 --- a/packages/analytics/src/environment.test.ts +++ b/packages/analytics/src/environment.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention -- analytics payload field names */ -/* eslint-disable-next-line id-length */ -import * as t from 'vitest'; +import { describe, beforeEach, it, expect } from 'vitest'; import { getInitializationContext, @@ -8,50 +7,47 @@ import { resetAnalyticsSessionForTests, } from './environment'; -t.describe('analytics session (environment)', () => { - t.beforeEach(() => { +describe('analytics session (environment)', () => { + beforeEach(() => { resetAnalyticsSessionForTests(); }); - t.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', - }); - t.expect(ctx).toEqual({ - sdk_version: '9.9.9', - anon_id: '11111111-2222-4333-8444-555555555555', - platform: 'nodejs', - }); - }, - ); + 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', + }); + }); - t.it('generates anon_id when omitted on first call only', () => { + it('generates anon_id when omitted on first call only', () => { const first = getInitializationContext({ sdk_version: '1.0.0' }); - t.expect(first.sdk_version).toBe('1.0.0'); - t.expect(first.platform).toBe('nodejs'); - t.expect(first.anon_id).toMatch( + 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' }); - t.expect(second.anon_id).toBe(first.anon_id); - t.expect(second.sdk_version).toBe('2.0.0'); + expect(second.anon_id).toBe(first.anon_id); + expect(second.sdk_version).toBe('2.0.0'); }); - t.it('getSessionBaseProperties throws before session start', () => { - t.expect(() => getSessionBaseProperties()).toThrow( + it('getSessionBaseProperties throws before session start', () => { + expect(() => getSessionBaseProperties()).toThrow( /getInitializationContext/iu, ); }); - t.it('getSessionBaseProperties returns copy after init', () => { + it('getSessionBaseProperties returns copy after init', () => { getInitializationContext({ sdk_version: '0.1.0' }); const a = getSessionBaseProperties(); const b = getSessionBaseProperties(); - t.expect(a).toEqual(b); - t.expect(a).not.toBe(b); + expect(a).toEqual(b); + expect(a).not.toBe(b); }); }); diff --git a/packages/analytics/src/sender.test.ts b/packages/analytics/src/sender.test.ts index d54fb8ce..e7fbe364 100644 --- a/packages/analytics/src/sender.test.ts +++ b/packages/analytics/src/sender.test.ts @@ -1,16 +1,17 @@ /* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable-next-line id-length */ -import * as t from 'vitest'; + +import type { Mock } from 'vitest'; +import { describe, beforeEach, afterEach, it, vi, expect } from 'vitest'; import Sender from './sender'; -t.describe('Sender', () => { - let sendFn: t.Mock<() => Promise>; +describe('Sender', () => { + let sendFn: Mock<() => Promise>; let sender: Sender; - t.beforeEach(() => { - t.vi.useFakeTimers(); - sendFn = t.vi.fn().mockResolvedValue(undefined); + beforeEach(() => { + vi.useFakeTimers(); + sendFn = vi.fn().mockResolvedValue(undefined); sender = new Sender({ batchSize: 2, baseTimeoutMs: 50, @@ -20,102 +21,96 @@ t.describe('Sender', () => { }); }); - t.afterEach(() => { - t.vi.useRealTimers(); - t.vi.clearAllMocks(); + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); }); - t.it('should flush after timeout', async () => { + it('should flush after timeout', async () => { sender.enqueue('event1'); - await t.vi.advanceTimersByTimeAsync(50); - t.expect(sendFn).toHaveBeenCalledWith(['event1']); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledWith(['event1']); }); - t.it('should flush twice with correct batch size', async () => { + it('should flush twice with correct batch size', async () => { sender.enqueue('event1'); sender.enqueue('event2'); sender.enqueue('event3'); - t.expect(sendFn).toHaveBeenCalledTimes(0); + expect(sendFn).toHaveBeenCalledTimes(0); - await t.vi.advanceTimersByTimeAsync(50); - t.expect(sendFn).toHaveBeenCalledTimes(1); - t.expect(sendFn).toHaveBeenCalledWith(['event1', 'event2']); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(1); + expect(sendFn).toHaveBeenCalledWith(['event1', 'event2']); - await t.vi.advanceTimersByTimeAsync(50); - t.expect(sendFn).toHaveBeenCalledTimes(2); - t.expect(sendFn).toHaveBeenCalledWith(['event3']); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(2); + expect(sendFn).toHaveBeenCalledWith(['event3']); }); - t.it( - 'should handle failure (with exponential backoff) and reset base timeout after successful send', - async () => { - let shouldSendFail = true; - sendFn = t.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, - }); + 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'); - t.expect(sendFn).toHaveBeenCalledTimes(0); - - await t.vi.advanceTimersByTimeAsync(50); - t.expect(sendFn).toHaveBeenCalledTimes(1); - t.expect(sendFn).toHaveBeenCalledWith(['event1']); - - shouldSendFail = false; - - await t.vi.advanceTimersByTimeAsync(50); - t.expect(sendFn).toHaveBeenCalledTimes(1); - await t.vi.advanceTimersByTimeAsync(50); - t.expect(sendFn).toHaveBeenCalledTimes(2); - t.expect(sendFn).toHaveBeenCalledWith(['event1']); - - sender.enqueue('event2'); - await t.vi.advanceTimersByTimeAsync(50); - t.expect(sendFn).toHaveBeenCalledTimes(3); - t.expect(sendFn).toHaveBeenCalledWith(['event2']); - }, - ); - - t.it( - 'disables and purges when maxFailureCount is reached; enqueue is noop', - async () => { - sendFn = t.vi.fn().mockRejectedValue(new Error('Failed')); - sender = new Sender({ - batchSize: 100, - baseTimeoutMs: 50, - maxFailureCount: 2, - 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']); - sender.enqueue('event1'); - await t.vi.advanceTimersByTimeAsync(50); - t.expect(sendFn).toHaveBeenCalledTimes(1); + shouldSendFail = false; - await t.vi.advanceTimersByTimeAsync(100); - t.expect(sendFn).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(50); + expect(sendFn).toHaveBeenCalledTimes(2); + expect(sendFn).toHaveBeenCalledWith(['event1']); - sender.enqueue('event2'); - await t.vi.advanceTimersByTimeAsync(60_000); - t.expect(sendFn).toHaveBeenCalledTimes(2); - }, - ); + 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); + }); - t.it('should handle concurrent sends properly', async () => { + it('should handle concurrent sends properly', async () => { let resolveSend!: (value?: unknown) => void; - sendFn = t.vi.fn().mockImplementation(async () => { + sendFn = vi.fn().mockImplementation(async () => { await new Promise((resolve) => { resolveSend = resolve; }); @@ -129,16 +124,16 @@ t.describe('Sender', () => { }); sender.enqueue('event1'); - await t.vi.advanceTimersByTimeAsync(1000); - t.expect(sendFn).toHaveBeenCalledWith(['event1']); - t.expect(sendFn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(sendFn).toHaveBeenCalledWith(['event1']); + expect(sendFn).toHaveBeenCalledTimes(1); sender.enqueue('event2'); resolveSend(); await Promise.resolve(); - await t.vi.advanceTimersByTimeAsync(1000); - t.expect(sendFn).toHaveBeenCalledWith(['event2']); - t.expect(sendFn).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1000); + expect(sendFn).toHaveBeenCalledWith(['event2']); + expect(sendFn).toHaveBeenCalledTimes(2); }); });