Skip to content

[AGI-550] - Tag tracking events with the library even on a customer's cioClient - #126

Open
joesurf wants to merge 2 commits into
mainfrom
agi-550-library-tag-on-tracking
Open

joesurf wants to merge 2 commits into
mainfrom
agi-550-library-tag-on-tracking

Conversation

@joesurf

@joesurf joesurf commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When a customer passes their own cioClient, our tracking events used to carry that client's c tag (e.g. ciojs-client-2.94.0). Those events then looked like a custom UI, so we couldn't tell which customers really use this library. This is the ASA version of Constructor-io/constructorio-ui-pia#177.

  • Our tracking events always send c=cio-ui-asa-<version>. useAsaTracking sends through a view of the customer's tracker that changes only version. This covers every ASA event: submit, result load started/finished, result click/view, search submit and agent button click.
  • Everything else still comes from the customer's client: API key, clientId, sessionId, userId, segments, test cells, service URL, request queue and event listeners. These are read when each event is sent, so later setClientOptions calls (e.g. login) still apply.
  • The customer's own events keep their tag. Only the events this library sends are retagged, and their client isn't modified. A customer who also sends ASA events from their own code (e.g. Catalyst) still shows up as both library and custom.
  • Only tracking changes. Agent API requests still go through the customer's client unchanged.
  • The cio-ui-asa-<version> string is now one constant, LIBRARY_TAG, shared with useCioClient.

…ioClient

A customer who passes their own cioClient sent our tracking events with
their client's `c` tag, so the events couldn't be attributed to the
library. useAsaTracking now sends through a view of the customer's
tracker whose `version` is `cio-ui-asa-<version>`; every other option is
still read from the customer's client at send time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joesurf
joesurf requested a review from a team as a code owner October 16, 2026 14:21
constructor-claude-bedrock[bot]

This comment was marked as outdated.

…through useAsaTracking

- Read the host's options on every event, so a replaced options object is followed
- Send with the host's tag instead of throwing when its options cannot be tagged (e.g. frozen)
- Retry a call that fails on the tagged tracker on the host's own tracker
- Test the tag through useAsaTracking with a real host client
- Document the tag on the provider's cioClient prop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@constructor-claude-bedrock constructor-claude-bedrock Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This PR introduces withLibraryTag to ensure ASA tracking events always carry cio-ui-asa-<version> as the c tag, even when a customer supplies their own cioClient — a clean, well-tested solution with solid fallback guards.

Inline comments: 5 discussions added

Overall Assessment: ⚠️ Needs Work

Comment thread src/utils/libraryTag.ts
const method = value as Method;
let wrapped = wrappedMethods.get(method);
if (!wrapped) {
wrapped = (...args) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important Issue: The fallback retry silently swallows all errors, not just the narrowly-documented "method only works on the real instance" case. If a tracking call fails for any other reason (network error surfaced synchronously, bad arguments, a bug in a future ciojs version), the retry on the bare target will silently re-attempt it and also swallow that failure. Callers lose all visibility.

The PR description says "a failing call is retried on the customer's tracker" and lists this as a virtue, but it also says "the fallback retry could send an event twice if a future ciojs threw after queuing it". That double-fire risk is real. Consider only retrying on a specific, recognisable error (e.g. instanceof TypeError with a message matching a private-field access) and re-throwing everything else, or at least logging in non-production environments:

wrapped = (...args) => {
  try {
    return method.apply(wrapper, args);
  } catch (e) {
    // Only retry binding errors; everything else re-throws so callers aren't silenced.
    if (!(e instanceof TypeError)) throw e;
    return method.apply(target, args);
  }
};

Comment thread src/utils/libraryTag.ts
return descriptor?.configurable === false && descriptor.writable === false;
}

function canTag(options: unknown): options is Options {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: canTag checks Object.isExtensible to guard against frozen objects, but the configurable !== false check on version's descriptor only inspects whether the property is currently non-configurable — it does not guard against the property becoming non-configurable after withLibraryTag is called (e.g. via Object.defineProperty later). The comment on isLocked notes "A proxy may only report a value other than the target's while that property could still change", but canTag is only called once at wrap-time for the initial options object, and then re-called inside currentOptions via !canTag(options) each time an event fires. This is fine for the options object itself, but worth a short comment explaining that canTag is intentionally re-evaluated per-send to catch post-wrap mutations.

Comment thread src/utils/libraryTag.ts
export function withLibraryTag<T extends object>(tracker: T): T {
const readOptions = () => (tracker as { options?: unknown }).options;
const initial = readOptions();
if (!canTag(initial) || initial.version === LIBRARY_TAG) return tracker;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The early-return check (initial.version === LIBRARY_TAG) uses the value at wrap time. This is correct for the library's own clients (constructed with version: LIBRARY_TAG), but the identity check is based on string equality rather than object identity. If a host somehow sets version to the same string as LIBRARY_TAG (highly unlikely, but possible), their tracker would be returned unwrapped. This is almost certainly fine, but a short comment noting "already tagged by us or matches our tag" would make the intent clearer for future maintainers.

Comment thread spec/libraryTag.test.ts
expect(lastEvent().query.get('c')).toBe('ciojs-client-host');
});

it('returns a tracker without options unchanged', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: This test covers the canTag(initial) guard, but there's no test for the case where options exists but version has a non-configurable descriptor (the configurable === false branch of canTag). That branch is exercised by an internal ciojs implementation detail; adding a test for it would protect against a future ciojs change hardening the descriptor silently removing tagging without a test failure. Something like:

it('returns a tracker with a non-configurable version property unchanged', () => {
  const tracker = { options: Object.defineProperty({}, 'version', {
    value: 'ciojs-client-host',
    configurable: false,
    writable: false,
    enumerable: true,
  }) };
  expect(withLibraryTag(tracker)).toBe(tracker);
});

threadId,
}: UseAsaTrackingProps): UseAsaTrackingReturn {
// Tagged even on a host-supplied client, so our events are attributed to the library.
const tracker = useMemo(() => hostTracker && withLibraryTag(hostTracker), [hostTracker]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: withLibraryTag returns the tracker unchanged when it's already tagged (or can't be tagged), so the useMemo is always safe. However, the memoisation only keyed on hostTracker means that if a host replaces the options object on their tracker after mount (as tested in spec/libraryTag.test.ts: 'follows an options object the host replaces'), the useMemo won't re-run — which is fine because withLibraryTag's proxy already handles that dynamically.

This is correct behaviour, but the comment only says "Tagged even on a host-supplied client". A brief note that withLibraryTag is stable/idempotent — so the memo key need not include options — would save a future reader from questioning the missing options dependency.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant