Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d65589e
fix: gate identify on the tracking policy, and make wallet markers ex…
yosriady Sep 9, 2026
d63009c
fix: supersede identify markers per wallet-user, as on web
yosriady Sep 9, 2026
b4d0ba5
fix: sparse arrays and toJSON in stable serialization; legacy marker …
yosriady Sep 9, 2026
f795ce4
test: legacy markers keep the shared timestamp; sparse arrays and toJ…
yosriady Sep 9, 2026
2186bee
fix: canonical serialization follows JSON.stringify for wrappers, toJ…
yosriady Sep 9, 2026
80fbade
fix: toJSON gets its property key, wrappers unbox through built-ins; …
yosriady Sep 9, 2026
34592a0
test: silence the wrapper-type lint in the boxed String case
yosriady Sep 9, 2026
238fea6
fix: toJSON runs once per property; a truncated or migrated marker se…
yosriady Sep 9, 2026
6939abe
fix: a function value's toJSON hook is honored and the hook is read once
yosriady Sep 9, 2026
08c81ef
fix: toJSON hooks apply through Reflect.apply, bigint hooks included
yosriady Sep 9, 2026
414c447
fix: boxed bigints reject; array length read once
yosriady Sep 9, 2026
1e63d27
fix: guard the BigInt check for runtimes without BigInt
yosriady Sep 9, 2026
e1eb46d
refactor: share the marker set helpers; simplify the identify dedup a…
yosriady Sep 9, 2026
0c5f278
refactor: the serializer matches the web SDK byte for byte
yosriady Sep 9, 2026
7806576
fix: identify() sends the effective user id it dedups on
yosriady Sep 9, 2026
a6219f8
fix: a bigint returned by a toJSON hook throws, as JSON.stringify does
yosriady Sep 9, 2026
c9e0193
fix: a boxed bigint returned by a toJSON hook throws, as JSON.stringi…
yosriady Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions src/FormoAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,14 @@ export class FormoAnalytics implements IFormoAnalytics {
try {
const { userId, address, providerName, rdns } = params;
logger.info("Identify", address, userId, providerName, rdns);
if (this.hasOptedOutTracking()) return;
// The full policy, not consent alone: identify persists the user id
// and marks the wallet before trackEvent applies the chain gate, and
// a marker written on an excluded chain would silence a later
// identify on an allowed one.
if (!this.shouldTrack()) {
logger.info("identify() skipped: tracking is suppressed for this visitor or chain");
return;
}

let validAddress: Address | undefined = undefined;
if (address) {
Expand All @@ -723,26 +730,21 @@ export class FormoAnalytics implements IFormoAnalytics {
storage().set(SESSION_USER_ID_KEY, userId);
}

// Check for duplicate identify
const isAlreadyIdentified = validAddress
? this.session.isWalletIdentified(validAddress, rdns || "")
: false;

if (isAlreadyIdentified) {
logger.info(
`Identify: Wallet ${providerName || "Unknown"} with address ${validAddress} already identified`
);
return;
}

// Mark as identified
// Dedup on the effective user, as the wire event carries it, not only
// the argument: a re-identify that changes the user is sent again.
if (validAddress) {
this.session.markWalletIdentified(validAddress, rdns || "");
if (this.session.isWalletIdentified(validAddress, rdns || "", this.currentUserId, properties)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send the effective user ID used by identify deduplication

When identify() omits userId after a user was restored from storage or set by an earlier call, this checks and writes the marker under this.currentUserId, but the payload at line 747 still contains the undefined argument. Fresh evidence against the earlier thread's premise is that EventFactory.create() passes event.userId directly to generateIdentifyEvent() (EventFactory.ts:815-821), which writes it to user_id, so the separate current-user argument passed to EventManager does not reach the identify event. The resulting anonymous identify is marked as though it belonged to the current user, and a later explicit identify for that user is suppressed; pass the same effective user ID in the identify payload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7806576: the identify payload carries the effective user id, the same value the marker is keyed on. Test: "identify() without a user id carries the effective user on the wire, as the marker does".

logger.info(
`Identify: Wallet ${providerName || "Unknown"} with address ${validAddress} already identified`
);
return;
}
this.session.markWalletIdentified(validAddress, rdns || "", this.currentUserId, properties);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow identify profiles to revert within the dedup window

When the same wallet-user is identified with { plan: "free" }, then pro, then free again within one minute and the context remains stable, this updates the persisted marker to free before enqueueing the third event. EventQueue still retains the first free fingerprint, and its content-and-minute message ID is also identical, so that third profile update is dropped or collapsed; the backend remains at pro while subsequent free calls match this marker and are suppressed for up to a day. Profile transitions need to be able to reuse an earlier value after an intervening identify, and the marker should not claim that transition was accepted when generic deduplication rejected it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. A profile that goes free, pro, free inside one minute meets the rolling duplicate window, which the two SDKs share and which was reviewed and kept as is (dedup design items were explicitly left out of this round). The marker records what the SDK accepted at the session level; the queue window is a separate, documented layer.

}

await this.trackEvent(
EventType.IDENTIFY,
{ address: validAddress, providerName, userId, rdns },
{ address: validAddress, providerName, userId: this.currentUserId, rdns },
properties,
context,
callback
Expand All @@ -764,7 +766,7 @@ export class FormoAnalytics implements IFormoAnalytics {
// The full policy, not consent alone: a detect refused by the chain gate
// after the marker is written would silence the wallet for the session.
if (!this.shouldTrack()) {
logger.info("detect() skipped: tracking is suppressed for this wallet or chain");
logger.info("detect() skipped: tracking is suppressed for this visitor or chain");
return;
}
if (this.session.isWalletDetected(rdns)) {
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/FormoAnalytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,19 @@ describe('FormoAnalytics', () => {
expect(mockEventManager.addEvent).not.toHaveBeenCalled();
});

it('identify() without a user id carries the effective user on the wire, as the marker does', async () => {
analytics.currentUserId = 'restored-user';
mockEventManager.addEvent.mockClear();

await analytics.identify({ address: '0x51377e9B985Bb90B7c091B9a7d30C93d4c9c1CEf', rdns: 'io.metamask' });

const event = mockEventManager.addEvent.mock.calls.at(-1)?.[0];
expect(event.userId).toBe('restored-user');
expect(mockSession.markWalletIdentified).toHaveBeenCalledWith(
'0x51377e9B985Bb90B7c091B9a7d30C93d4c9c1CEf', 'io.metamask', 'restored-user', undefined
);
});

it('treats a null or undefined idempotency_key as no key and strips the property', async () => {
for (const idempotency_key of [undefined, null]) {
mockEventManager.addEvent.mockClear();
Expand Down Expand Up @@ -815,6 +828,78 @@ describe('FormoAnalytics', () => {
});
});

describe('identify() tracking gate', () => {
const address = '0x742d35cc6634c0532925a3b844bc9e7595f3f6d2';

it('writes nothing on an excluded chain, and identifies on an allowed one', async () => {
analytics.options.tracking = { excludeChains: [1] };
analytics.currentChainId = 1;

await analytics.identify({ address, userId: 'user-1', rdns: 'io.metamask' });

expect(analytics.currentAddress).toBeUndefined();
expect(analytics.currentUserId).toBeUndefined();
expect(mockStorageInstance.set).not.toHaveBeenCalledWith('user_id', expect.anything());
expect(mockSession.markWalletIdentified).not.toHaveBeenCalled();
expect(mockEventManager.addEvent).not.toHaveBeenCalled();

analytics.currentChainId = 137;
await analytics.identify({ address, userId: 'user-1', rdns: 'io.metamask' });

expect(mockStorageInstance.set).toHaveBeenCalledWith('user_id', 'user-1');
expect(mockSession.markWalletIdentified).toHaveBeenCalledWith(
expect.any(String),
'io.metamask',
'user-1',
undefined
);
expect(mockEventManager.addEvent).toHaveBeenCalledWith(
expect.objectContaining({ type: 'identify', userId: 'user-1' }),
expect.any(String),
'user-1'
);
});

it('writes nothing when tracking is off', async () => {
analytics.options.tracking = false;

await analytics.identify({ address, userId: 'user-1' });

expect(analytics.currentAddress).toBeUndefined();
expect(mockStorageInstance.set).not.toHaveBeenCalledWith('user_id', expect.anything());
expect(mockSession.markWalletIdentified).not.toHaveBeenCalled();
expect(mockEventManager.addEvent).not.toHaveBeenCalled();
});

it('checks and marks the wallet with the user id and the properties', async () => {
const properties = { plan: 'pro' };

await analytics.identify({ address, userId: 'user-1', rdns: 'io.metamask' }, properties);

expect(mockSession.isWalletIdentified).toHaveBeenCalledWith(
expect.any(String),
'io.metamask',
'user-1',
properties
);
expect(mockSession.markWalletIdentified).toHaveBeenCalledWith(
expect.any(String),
'io.metamask',
'user-1',
properties
);
});

it('skips the event when the session already holds this identity', async () => {
mockSession.isWalletIdentified.mockReturnValue(true);

await analytics.identify({ address, userId: 'user-1' });

expect(mockSession.markWalletIdentified).not.toHaveBeenCalled();
expect(mockEventManager.addEvent).not.toHaveBeenCalled();
});
});

describe('optOutTracking()', () => {
it('should clear the anonymous id and the stored attribution as well', () => {
analytics.optOutTracking();
Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/eventManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,44 @@ describe("EventManager custom-event identity", () => {
expect(b![3].dedupKey).toBe(a![3].dedupKey);
});

it("fingerprints the same properties the same whatever their key order", async () => {
const { queue, manager } = makeManager();

await manager.addEvent({ type: "track", event: "Purchase", properties: { currency: "USD", amount: 10 } });
await manager.addEvent({ type: "track", event: "Purchase", properties: { amount: 10, currency: "USD" } });

const [a, b] = enqueueCalls(queue);
expect(b![3].dedupKey).toBe(a![3].dedupKey);
});

it("sorts keys in nested objects too", async () => {
const { queue, manager } = makeManager();

await manager.addEvent({
type: "track",
event: "Purchase",
properties: { item: { sku: "x", qty: 1 }, currency: "USD" },
});
await manager.addEvent({
type: "track",
event: "Purchase",
properties: { currency: "USD", item: { qty: 1, sku: "x" } },
});

const [a, b] = enqueueCalls(queue);
expect(b![3].dedupKey).toBe(a![3].dedupKey);
});

it("keeps array order in the fingerprint", async () => {
const { queue, manager } = makeManager();

await manager.addEvent({ type: "track", event: "Purchase", properties: { tags: ["a", "b"] } });
await manager.addEvent({ type: "track", event: "Purchase", properties: { tags: ["b", "a"] } });

const [a, b] = enqueueCalls(queue);
expect(b![3].dedupKey).not.toBe(a![3].dedupKey);
});

it("keeps caller-supplied context in the fingerprint", async () => {
const { queue, manager } = makeManager();
const call = { type: "track" as const, event: "Order Placed", properties: { market: "ZEC" } };
Expand Down
50 changes: 48 additions & 2 deletions src/__tests__/eventQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,9 @@ describe("EventQueue", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);

// Opt out, then the same event is produced again and re-enqueued.
// Only sends after the opt-out count from here on.
queue.clear();
fetchMock.mockClear();
await queue.enqueue(makeEvent(1));
await settle();

Expand All @@ -524,8 +526,6 @@ describe("EventQueue", () => {
await settle();

// A third identical enqueue must still be recognised as a duplicate.
fetchMock.mockClear();
fetchMock.mockResolvedValue({ ok: true, status: 200 });
await queue.enqueue(makeEvent(1));
await queue.flush();
await settle();
Expand All @@ -539,6 +539,26 @@ describe("EventQueue", () => {
});
});

describe("first event after clear()", () => {
it("ships at once instead of waiting for the batch interval", async () => {
const queue = makeQueue({ flushAt: 20 });

await queue.enqueue(makeEvent(1));
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);

// Opt out, then opt in: the next event starts a fresh lifecycle.
queue.clear();
await queue.enqueue(makeEvent(2));
await settle();

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(sentEvents().map((e) => e.event)).toEqual(["event-1", "event-2"]);

await queue.cleanup();
});
});

describe("enqueue racing clear()", () => {
it("drops an event whose hashing was still pending when the user opted out", async () => {
const queue = makeQueue();
Expand Down Expand Up @@ -1161,6 +1181,32 @@ describe("EventQueue dedup window", () => {
}
});

it("collapses two events whose properties differ only in key order", async () => {
const queue = makeQueue();

await queue.enqueue(event({ properties: { currency: "USD", amount: 10 } }));
await settle();
await queue.enqueue(event({ properties: { amount: 10, currency: "USD" } }));
await queue.flush();
await settle();

expect(sent()).toHaveLength(1);
await queue.cleanup();
});

it("keeps two events whose array order differs", async () => {
const queue = makeQueue();

await queue.enqueue(event({ properties: { tags: ["a", "b"] } }));
await settle();
await queue.enqueue(event({ properties: { tags: ["b", "a"] } }));
await queue.flush();
await settle();

expect(sent()).toHaveLength(2);
await queue.cleanup();
});

it("gives separate unkeyed custom events distinct random ids", async () => {
const queue = makeQueue();

Expand Down
Loading