Skip to content

Commit 63c0523

Browse files
author
Nikhil Agrawal
committed
mobile-apps: add app.json app identity to telemetry
Switch app correlation to app.json-based appInstanceId, pass resolved working-dir context into hook telemetry emission, and emit appInstanceId as null when unavailable. Update create-mobile-app flow to mint app identity and refresh focused telemetry tests.
1 parent 06a417e commit 63c0523

6 files changed

Lines changed: 281 additions & 5 deletions

File tree

plugins/mobile-apps/hooks/run-telemetry.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,45 @@ function invocationFor(mode, payload) {
3030
return null;
3131
}
3232

33+
function pickString(...values) {
34+
for (const value of values) {
35+
if (typeof value === 'string' && value.trim()) return value.trim();
36+
}
37+
return '';
38+
}
39+
40+
function parseWorkingDirArg(text) {
41+
if (typeof text !== 'string' || !text.trim()) return '';
42+
const match = text.match(/--working-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|(\S+))/i);
43+
return pickString(match && match[1], match && match[2], match && match[3]);
44+
}
45+
46+
function resolveInvocationCwd(payload) {
47+
const toolInput = payload && typeof payload.tool_input === 'object' ? payload.tool_input : null;
48+
const fromToolInput = pickString(
49+
toolInput && toolInput.cwd,
50+
toolInput && toolInput.working_dir,
51+
toolInput && toolInput.workingDir,
52+
);
53+
if (fromToolInput) return fromToolInput;
54+
55+
const fromToolArgs = toolInput
56+
? pickString(
57+
parseWorkingDirArg(toolInput.arguments),
58+
parseWorkingDirArg(toolInput.args),
59+
parseWorkingDirArg(toolInput.command),
60+
parseWorkingDirArg(toolInput.prompt),
61+
)
62+
: '';
63+
if (fromToolArgs) return fromToolArgs;
64+
65+
return pickString(
66+
payload && payload.working_dir,
67+
payload && payload.workingDir,
68+
payload && payload.cwd,
69+
);
70+
}
71+
3372
async function run(mode) {
3473
let payload;
3574
try {
@@ -42,7 +81,13 @@ async function run(mode) {
4281
if (!skillName) return;
4382

4483
const context = telemetry.createTelemetryContext(payload);
45-
if (context) telemetry.emitSkillStarted(context, { skillName, source: mode });
84+
if (context) {
85+
telemetry.emitSkillStarted(
86+
context,
87+
{ skillName, source: mode },
88+
{ cwd: resolveInvocationCwd(payload) },
89+
);
90+
}
4691
}
4792

4893
function start(mode) {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use strict';
2+
3+
const crypto = require('node:crypto');
4+
const fs = require('node:fs');
5+
const path = require('node:path');
6+
7+
const APP_JSON_FILE = 'app.json';
8+
const APP_INSTANCE_ID =
9+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10+
11+
function isPlainObject(value) {
12+
return !!value && typeof value === 'object' && !Array.isArray(value);
13+
}
14+
15+
function readJsonFile(filePath) {
16+
try {
17+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
18+
} catch {
19+
return null;
20+
}
21+
}
22+
23+
function appJsonPath(projectRoot) {
24+
return path.join(path.resolve(projectRoot), APP_JSON_FILE);
25+
}
26+
27+
function readAppInstanceId(projectRoot) {
28+
const appJson = readJsonFile(appJsonPath(projectRoot));
29+
if (!isPlainObject(appJson) || !isPlainObject(appJson.expo)) return '';
30+
31+
const extra = appJson.expo.extra;
32+
const identity = isPlainObject(extra) && isPlainObject(extra.powerPlatformSkills)
33+
? extra.powerPlatformSkills
34+
: null;
35+
const appInstanceId = identity && typeof identity.appInstanceId === 'string'
36+
? identity.appInstanceId
37+
: '';
38+
39+
return APP_INSTANCE_ID.test(appInstanceId) ? appInstanceId : '';
40+
}
41+
42+
function findAppInstanceId(projectRoot = process.cwd()) {
43+
if (!projectRoot) return '';
44+
return readAppInstanceId(projectRoot);
45+
}
46+
47+
function ensureAppInstanceId(projectRoot = process.cwd()) {
48+
const root = path.resolve(projectRoot);
49+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
50+
throw new Error('Cannot create app identity outside an existing directory');
51+
}
52+
53+
const existing = readAppInstanceId(root);
54+
if (existing) return existing;
55+
56+
const filePath = appJsonPath(root);
57+
const appJson = readJsonFile(filePath);
58+
const next = isPlainObject(appJson) ? appJson : {};
59+
next.expo = isPlainObject(next.expo) ? next.expo : {};
60+
next.expo.extra = isPlainObject(next.expo.extra) ? next.expo.extra : {};
61+
const skillIdentity = isPlainObject(next.expo.extra.powerPlatformSkills)
62+
? next.expo.extra.powerPlatformSkills
63+
: {};
64+
65+
const appInstanceId = crypto.randomUUID();
66+
next.expo.extra.powerPlatformSkills = {
67+
...skillIdentity,
68+
schemaVersion: 1,
69+
appInstanceId,
70+
};
71+
72+
fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
73+
return appInstanceId;
74+
}
75+
76+
module.exports = {
77+
APP_JSON_FILE,
78+
ensureAppInstanceId,
79+
findAppInstanceId,
80+
};
81+
82+
if (require.main === module) {
83+
process.stdout.write(`${ensureAppInstanceId(process.argv[2] || process.cwd())}\n`);
84+
}

plugins/mobile-apps/scripts/lib/mobile-telemetry.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const events = require('./telemetry/lib/events');
1313
const { fireAndForget } = require('./telemetry/lib/emit-spawn');
1414
const { loadResolver } = require('./telemetry/lib/resolver-loader');
1515
const session = require('./telemetry/lib/session');
16+
const { findAppInstanceId } = require('./app-identity');
1617

1718
function readPluginVersion() {
1819
const manifestPath = path.resolve(__dirname, '..', '..', '.claude-plugin', 'plugin.json');
@@ -236,7 +237,15 @@ function commonFields(context, invocation, opts = {}) {
236237
nodeVersion: `v${String(process.versions.node).split('.')[0]}`,
237238
skillName: invocation.skillName,
238239
};
239-
if (invocation.source) fields.eventInfo = { invocationSource: invocation.source };
240+
241+
// `eventInfo` is the shared schema's caller-supplied JSON field, so app
242+
// identity rides here rather than needing a new allowlisted column.
243+
const eventInfo = {};
244+
if (invocation.source) eventInfo.invocationSource = invocation.source;
245+
const appInstanceId = findAppInstanceId(opts.cwd) || null;
246+
eventInfo.appInstanceId = appInstanceId;
247+
if (Object.keys(eventInfo).length) fields.eventInfo = eventInfo;
248+
240249
if (ai.aiAgentName) fields.aiAgentName = ai.aiAgentName;
241250
if (ai.aiAgentVersion) fields.aiAgentVersion = ai.aiAgentVersion;
242251
return fields;

plugins/mobile-apps/scripts/tests/mobile-telemetry-hooks.test.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,10 @@ test('prompt hook builds the Mobile Apps CS4 envelope without real network acces
243243
assert.match(envelope.data.nodeVersion, /^v\d+$/);
244244
assert.equal(envelope.data.skillName, 'deploy');
245245
assert.equal(typeof envelope.data.eventInfo, 'string');
246-
assert.deepEqual(JSON.parse(envelope.data.eventInfo), { invocationSource: 'prompt' });
246+
assert.deepEqual(JSON.parse(envelope.data.eventInfo), {
247+
invocationSource: 'prompt',
248+
appInstanceId: null,
249+
});
247250
for (const forbidden of [
248251
'orgId',
249252
'tenantId',
@@ -275,6 +278,7 @@ test('bare command outside a mobile project emits a start', (t) => {
275278
assert.equal(records.length, 1);
276279
assert.equal(records[0].data.skillName, 'deploy');
277280
assert.equal(records[0].data.eventInfo.invocationSource, 'prompt');
281+
assert.equal(records[0].data.eventInfo.appInstanceId, null);
278282
});
279283

280284
test('manual Copilot slash command emits after host expansion', (t) => {
@@ -289,6 +293,73 @@ test('manual Copilot slash command emits after host expansion', (t) => {
289293
assert.equal(records.length, 1);
290294
assert.equal(records[0].data.skillName, 'add-connector');
291295
assert.equal(records[0].data.eventInfo.invocationSource, 'prompt');
296+
assert.equal(records[0].data.eventInfo.appInstanceId, null);
297+
});
298+
299+
test('hook attaches app identity from app.json when cwd is a project root', (t) => {
300+
const context = fixture(t);
301+
fs.writeFileSync(
302+
path.join(context.projectRoot, 'app.json'),
303+
JSON.stringify({
304+
expo: {
305+
extra: {
306+
powerPlatformSkills: {
307+
schemaVersion: 1,
308+
appInstanceId: '5d7e71b1-61d6-4a91-9c2e-cba5db983e38',
309+
},
310+
},
311+
},
312+
}),
313+
);
314+
315+
assert.equal(runHook('pretool', {
316+
cwd: context.projectRoot,
317+
session_id: 'session-1',
318+
tool_input: { skill: 'deploy' },
319+
}, context).status, 0);
320+
321+
const records = waitForEvents(context, 1);
322+
assert.equal(records.length, 1);
323+
assert.equal(records[0].data.eventInfo.invocationSource, 'pretool');
324+
assert.equal(
325+
records[0].data.eventInfo.appInstanceId,
326+
'5d7e71b1-61d6-4a91-9c2e-cba5db983e38',
327+
);
328+
});
329+
330+
test('hook resolves --working-dir from tool input when cwd is outside the project', (t) => {
331+
const context = fixture(t);
332+
fs.writeFileSync(
333+
path.join(context.projectRoot, 'app.json'),
334+
JSON.stringify({
335+
expo: {
336+
extra: {
337+
powerPlatformSkills: {
338+
schemaVersion: 1,
339+
appInstanceId: 'e5b2f43c-fa9f-44a0-b8a7-54e4f956f9db',
340+
},
341+
},
342+
},
343+
}),
344+
);
345+
const unrelated = path.join(context.root, 'outside');
346+
fs.mkdirSync(unrelated);
347+
348+
assert.equal(runHook('pretool', {
349+
cwd: unrelated,
350+
session_id: 'session-1',
351+
tool_input: {
352+
skill: 'deploy',
353+
arguments: `--working-dir "${context.projectRoot}" --non-interactive`,
354+
},
355+
}, context).status, 0);
356+
357+
const records = waitForEvents(context, 1);
358+
assert.equal(records.length, 1);
359+
assert.equal(
360+
records[0].data.eventInfo.appInstanceId,
361+
'e5b2f43c-fa9f-44a0-b8a7-54e4f956f9db',
362+
);
292363
});
293364

294365
test('another plugin namespace and embedded command are no-ops', (t) => {

plugins/mobile-apps/scripts/tests/mobile-telemetry.test.js

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
createTelemetryContext,
1212
emitSkillStarted,
1313
} = require('../lib/mobile-telemetry');
14+
const { ensureAppInstanceId, findAppInstanceId } = require('../lib/app-identity');
1415

1516
const PLUGIN_ROOT = path.resolve(__dirname, '..', '..');
1617
const TELEMETRY_CLI = path.join(
@@ -31,6 +32,14 @@ function tempConfig(config) {
3132
return { dir, ikeyPath };
3233
}
3334

35+
// An empty directory keeps app-identity lookup out of the repo checkout, so
36+
// event assertions do not depend on where the suite happens to run.
37+
function tempProject(t) {
38+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mobile-app-project-'));
39+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
40+
return dir;
41+
}
42+
3443
function contextFor(config) {
3544
const { dir, ikeyPath } = tempConfig(config);
3645
return createTelemetryContext(
@@ -159,26 +168,81 @@ test('expired Copilot alias does not outlive unavailable host state', (t) => {
159168
assert.equal(expired.sessionId, nestedSessionId);
160169
});
161170

162-
test('started event is allowlisted and carries no user, tenant, prompt, or path data', () => {
171+
test('started event is allowlisted and carries no user, tenant, prompt, or path data', (t) => {
163172
const context = contextFor(provisioned);
164173
let captured;
165174
const event = emitSkillStarted(context, invocation, {
166175
emit: (value) => { captured = value; },
167176
readAiAgent: () => ({ aiAgentName: 'GitHub Copilot', aiAgentVersion: '1.2.3' }),
168177
correlationId: 'correlation-1',
178+
cwd: tempProject(t),
169179
});
170180
assert.equal(captured, event);
171181
assert.equal(event.data.eventName, 'skill_started');
172182
assert.equal(event.data.pluginName, 'mobile-app');
173183
assert.equal(event.data.skillName, 'deploy');
174184
assert.equal(event.data.sessionId, 'session-1');
175185
assert.equal(event.data.correlationId, 'correlation-1');
176-
assert.deepEqual(event.data.eventInfo, { invocationSource: 'pretool' });
186+
assert.deepEqual(event.data.eventInfo, { invocationSource: 'pretool', appInstanceId: null });
177187
for (const forbidden of ['orgId', 'tenantId', 'pacCliVersion', 'aadObjectId', 'prompt', 'cwd', 'path']) {
178188
assert.equal(Object.prototype.hasOwnProperty.call(event.data, forbidden), false);
179189
}
180190
});
181191

192+
test('app instance id is minted once and reused by later skill runs', (t) => {
193+
const project = tempProject(t);
194+
const appInstanceId = ensureAppInstanceId(project);
195+
assert.match(appInstanceId, /^[0-9a-f-]{36}$/);
196+
assert.equal(ensureAppInstanceId(project), appInstanceId);
197+
assert.equal(findAppInstanceId(project), appInstanceId);
198+
assert.equal(findAppInstanceId(path.join(project, 'src', 'screens')), '');
199+
});
200+
201+
test('events outside a project carry no app identity', (t) => {
202+
assert.equal(findAppInstanceId(tempProject(t)), '');
203+
});
204+
205+
test('a hand-edited app identity is ignored rather than emitted', (t) => {
206+
const project = tempProject(t);
207+
fs.writeFileSync(
208+
path.join(project, 'app.json'),
209+
JSON.stringify({
210+
expo: {
211+
extra: {
212+
powerPlatformSkills: {
213+
schemaVersion: 1,
214+
appInstanceId: 'contoso-field-inspections',
215+
},
216+
},
217+
},
218+
}),
219+
);
220+
assert.equal(findAppInstanceId(project), '');
221+
});
222+
223+
test('two apps in one session emit distinct app identities', (t) => {
224+
const context = contextFor(provisioned);
225+
const emitFrom = (project) => emitSkillStarted(context, invocation, {
226+
emit: () => {},
227+
readAiAgent: () => ({}),
228+
cwd: project,
229+
}).data;
230+
231+
const first = emitFrom(tempProject(t));
232+
const second = emitFrom(tempProject(t));
233+
assert.equal(first.sessionId, second.sessionId);
234+
assert.equal(first.eventInfo.appInstanceId, null);
235+
236+
const projectA = tempProject(t);
237+
const projectB = tempProject(t);
238+
ensureAppInstanceId(projectA);
239+
ensureAppInstanceId(projectB);
240+
const a = emitFrom(projectA);
241+
const b = emitFrom(projectB);
242+
assert.notEqual(a.eventInfo.appInstanceId, b.eventInfo.appInstanceId);
243+
assert.equal(a.sessionId, b.sessionId);
244+
});
245+
182246
test('bundled telemetry library is byte-identical to the canonical shared source', (t) => {
183247
if (!fs.existsSync(SHARED_TELEMETRY_LIB)) {
184248
t.skip('canonical shared source is unavailable in an installed plugin');

plugins/mobile-apps/skills/create-mobile-app/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,8 +930,11 @@ This is the **Scaffold gate** from the TypeScript Gate Policy. If it fails, capt
930930

931931
```bash
932932
cp "${CLAUDE_SKILL_DIR}/../../shared/memory-bank.md" "<working_dir>/memory-bank.md"
933+
node "${CLAUDE_SKILL_DIR}/../../scripts/lib/app-identity.js" "<working_dir>"
933934
```
934935

936+
`app-identity.js` mints `app.json` `expo.extra.powerPlatformSkills.appInstanceId` — a random per-project ID that lets usage telemetry tell this app apart from other apps built in the same session, and recognize it again in later sessions. It is idempotent, so a resume or re-run keeps the original ID. It contains no app name, path, or environment data. Commit it: it is the app's identity, not a per-machine cache.
937+
935938
Fill in the Project facts and Power Platform context sections from Steps 2 and 4. From here on, every step appends to the relevant section of `<working_dir>/memory-bank.md` immediately after success — not at the end. This is what enables Step 0's resume on a future run.
936939

937940
Immediately after creating `memory-bank.md`, flush any queued planner concerns from `DEFERRED_CONCERNS[]` into `## Concerns` (append-only). This flush is unconditional: if the queue is non-empty, write it now before continuing to Step 6.75.

0 commit comments

Comments
 (0)