Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion plugins/mobile-apps/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ The Expo template snapshot ships bundled inside this plugin at `template/`. It i
| Delete `power.config.json` | `npx power-apps init` regenerates for the user's environment |
| Reset `src/generated/` + `src/hooks/` | Remove any example stubs — `npx power-apps add-data-source` repopulates |
| `app/_layout.tsx`: add `tamaguiConfig` + `defaultTheme` props | Screens render under brand tokens, not upstream defaults |
| `app.json` + `app/_layout.tsx`: `appConfig` | Pass the complete generated app configuration once so the host can resolve opt-in Application Insights settings inside fixed Dev Player |
| `tsconfig.json`: merge `@/` path aliases | `@/components`, `@/hooks`, `@/utils`, `@/tokens`, `@/generated`, `@/native` resolve |

Do not add preparation rewrites for `scheme`, `package`, `bundleIdentifier`, `src/playerConfig.ts`, `fingerprint.config.js`, or `native-runtime.json` unless those files exist in the synced main template.
Expand Down Expand Up @@ -73,14 +74,16 @@ Do not add preparation rewrites for `scheme`, `package`, `bundleIdentifier`, `sr
- `DONE_WITH_CONCERNS` requires at least one concern. If none, use `DONE`.
- Special early-return signals (`INDUSTRY_CONFIRM_REQUESTED:`, `DESIGN_VIBE_REQUESTED:`) pre-date this protocol and remain in effect — they are special-cased "ask the user one question and re-spawn me" handoffs, not terminal returns.
- The canonical orchestrator handler lives in [`skills/create-mobile-app/SKILL.md`](./skills/create-mobile-app/SKILL.md) Step 3.0. Future skills that spawn agents should reference it rather than duplicating the switch.
13. **Customer telemetry is Application Insights-specific and opt-in** — Each generated app targets one C1-owned, workspace-based Application Insights resource. `app.json` → `expo.extra.appInsightsConfig` defaults to disabled and stores its connection string, matching the Power Apps canvas-app model. Treat the value as sensitive project configuration: do not print it, write it to `memory-bank.md`, or include it in summaries. Keep `includeUserId` false unless explicitly approved.

## Telemetry

Mobile Apps bundles the canonical stdlib-only telemetry helpers from the repo-root `shared/telemetry/lib` at `scripts/lib/telemetry/lib`. Edit the shared source first, then refresh this physical copy in the same change; never copy another plugin's `ikey.json` or resolver.

- **Start-only lifecycle:** `UserPromptSubmit` records explicit slash-command starts and `PreToolUse(Skill)` records programmatic Skill-tool starts; both may fire for one visible slash command. `UserPromptSubmit` payloads differ by host — Claude Code passes the raw `/mobile-app:<skill>` text, Copilot CLI pre-expands it to a `<skill-context name="<skill>">` wrapper and emits no Skill pre-tool event — so both shapes must stay recognized or manual runs go uncaptured. Do not add `skill_completed`, duration, outcome, or persisted correlation state: Power Pages deliberately removed that flow because the hook boundary does not prove the workflow completed.
- **Coverage and attribution:** `scripts/lib/mobileapp-hook-utils.js` discovers every user- or model-invocable top-level skill, including `telemetry`. Direct-read helpers with `disable-model-invocation: true` are not independently invoked and are excluded. Bare and `mobile-app:`-qualified names are both attributed; explicitly foreign plugin namespaces are excluded.
- **Session correlation:** Stable host session ids pass through unchanged. Copilot CLI reports a transient `call_*` id to nested-agent hooks, so `resolveCopilotRootSessionId` in `scripts/lib/mobile-telemetry.js` resolves it to the unique recent UUID session whose local `~/.copilot/session-state/<uuid>/events.jsonl` structurally owns that `agentId`, reading only a bounded tail. Keep host-specific quirks contained in that one function. The verified root is cached as one atomic 30-minute alias file per hashed call id so fresh hook processes reuse it; aliases hold no prompts, cwd, or tool arguments and are never transmitted. Missing, stale, malformed, or ambiguous state fails open to the original id, and Claude Code and Codex ids are not rewritten.
- **Privacy:** Mobile Apps sends no prompt, tool input, cwd, path, URL, credential, username, hostname, Dataverse org/tenant ID, or Entra object ID. The dynamic `eventInfo` contains only `invocationSource` (`prompt` or `pretool`) and a random per-project `appInstanceId` (or `null` outside a prepared project).
- **Privacy:** Mobile Apps sends no prompt, tool input, cwd, path, URL, credential, username, hostname, Dataverse org/tenant ID, or Entra object ID. The dynamic `eventInfo` contains only `invocationSource` (`prompt` or `pretool`), a random per-project `appInstanceId` (or `null` outside a prepared project), and `appInsightsSelection` (`enabled` or `disabled`) after the `/create-mobile-app` prompt.
- **Destination and controls:** Mobile-owned `scripts/lib/mobile-telemetry-dispatcher.js` maps shared events to the Power Apps `event` stream configured by `scripts/lib/telemetry/ikey.json`. The repository `disabled` switch is a hard-off; user and CI opt-outs suppress transmission while preserving the local diagnostic mirror.
- **CI:** Every Mobile Apps test job must set `POWER_PLATFORM_SKILLS_TELEMETRY_MOBILE_APP_OPTOUT=1`. The single positive wire test clears that backstop only in its child process and routes the event to `POWER_PLATFORM_SKILLS_FAKE_HTTPS`; all other positive tests remain opted out and exercise the local mirror.

Expand Down
4 changes: 4 additions & 0 deletions plugins/mobile-apps/agents/native-app-planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ You will be invoked by `/create-mobile-app` with a prompt that includes:
Mermaid for diagrams.
- **Per-section approval gates.** You enter plan mode four times — once per section. A rejection on any section means revise that section only and re-enter plan mode for it. Do not move on until each section is explicitly approved.
- **Sequential then parallel.** Spawn `data-model-architect` first (alone). Plan native capabilities and connectors inline. Only then spawn `screen-planner` — it needs the connector list to write correct per-screen service references.
- **Application Insights is outside the plan gates.** Treat requests to enable telemetry for the generated app as host/runtime configuration owned by `/create-mobile-app` Step 6.8 or the `/edit-app` Application Insights fast path. Do not model Application Insights in the data model, native-capability matrix, connector list, or as a telemetry-specific screen. In particular, never propose the deprecated Azure Application Insights connector or a custom connector for telemetry ingestion. When the user explicitly names customer events, preserve them as `Customer telemetry` annotations on the corresponding normal business-screen specs; this records behavior without turning telemetry into a planning gate or data source.

- **Dataverse planning forwarding is verbatim.** Pass the planning mode to every
default-mode `data-model-architect` dispatch and revision. In `required`,
pass both planning-snapshot/evidence absolute paths unchanged. In `connector-only`,
Expand Down Expand Up @@ -298,6 +300,8 @@ Follow [`shared/references/connector-planning.md`](${PLUGIN_ROOT}/shared/referen

**Key rule:** Dataverse is NOT a connector. If requirements mention custom business data / tables, that belongs in `## Data Model`, not `## Connectors`.

Application Insights telemetry is also NOT a connector. Exclude it from Gate 3 and allow planning to proceed with the actual business connectors. The orchestrator configures the C1-owned Application Insights resource through `/create-mobile-app` Step 6.8 or `/edit-app`.

Store the confirmed connector list — you will pass it to `screen-planner` in Step 4.

## Step 4 — Assemble `native-app-plan.md`
Expand Down
1 change: 1 addition & 0 deletions plugins/mobile-apps/agents/screen-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ You will be invoked by `/create-mobile-app` Step 11 or `/edit-app` screen-rebuil
3. **If your spec's service is NOT in the table** (or your fallback Glob doesn't find it) — the data source has not been generated yet. Write the screen with the expected import path from your spec and add `// TODO(connector-not-yet-added): run /add-dataverse to generate <ServiceName>` above the call. Do NOT invent a service or rename to something that does exist.
4. **Method missing on a service that does exist** (table lists `getAll, get` but spec calls `update`) — do NOT silently substitute. Add `// TODO(method-missing): <ServiceName>.update not generated; expected per plan` and write the call as-if; orchestrator's tsc pass will catch it and surface to user.
- **Generated service results are non-throwing and create results may be sparse.** Power Apps generated services return `IOperationResult<T>` (`success`, `data`, `error`) and may not throw on failed network/API calls. After every `get`, `getAll`, `create`, `update`, or `delete` call, check `result.success`. If false, throw or surface `result.error` immediately. Never rely on `try/catch` alone and never read `result.data` without a preceding success check. For `create()`, success does **not** guarantee `result.data` contains the new primary ID; the SDK may return 204-style success with sparse/empty data. If the screen needs the new record ID for navigation, child rows, lookup binds, or file/photo upload, follow the create-then-navigate rule below and pre-generate the ID with `newId()`. Bug prevented: visible buttons that silently no-op because the initial load/create failed and `record` stayed null; detail routes like `/scanner/undefined` that call Dataverse as `table(undefined)`.
- **Customer telemetry is explicit and customer-only.** If and only if the assigned screen spec contains a `Customer telemetry` entry, import `getCustomerTelemetryLogger` from `@microsoft/power-apps-native-host` and emit exactly those named events at the specified successful/failure trigger. Never use `getAppLogger()` for customer-defined events. Pass only the approved scalar properties from the spec; do not add form values, free text, record titles, personal identifiers, tokens, precise coordinates, nested objects, or complete URLs. If customer telemetry is disabled, the customer logger is a safe no-op.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe we should have a common logger, where we can have custom fields specefic to app insights

- **Dataverse display labels — use the `lookupName` / `formattedValue` helpers from `@/utils`, never inline annotation reads or guessed `*name` fields.** This is a hard rule for list rows, detail fields, search/filter fields, and empty-state summaries. Do NOT select, read, search, or destructure invented lookup/choice display fields such as separate `*idname`, `*statusname`, `*owneridname`, `*createdbyname`, or other `*name` shadow properties — those virtual columns are NOT queryable on custom entities and putting them in `$select` returns HTTP 400 on every list read. Do NOT inline the raw annotation key (`record['_<lookup>_value@OData.Community.Display.V1.FormattedValue']`) either — it is brittle and easy to typo. Always:

```tsx
Expand Down
2 changes: 2 additions & 0 deletions plugins/mobile-apps/agents/screen-planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,8 @@ For each screen the user adds, provide this compact shape:
- **Profile content** — REQUIRED on the Profile screen only. List 2-4 app-specific profile sections based on the app requirements and target users, for example `Role + team`, `Assigned site/territory`, `Default queue filters`, `App support/contact`, `Environment/app version`. Include any generated service needed for those sections; otherwise use local/static app context plus `useAuth()`.
- **Sign-out affordance** — REQUIRED on the Profile screen and omitted from every other screen. Write `visible Button "Sign out" using useAuth().signOut with confirm`; sign-out returns to `/login` after completion.
- **Data** — which generated services it calls, with method names (e.g., bounded lookup: `AccountsService.getAll({ top: 50, orderBy: ['name asc'], select: ['name'] })`; cursor list: `InspectionsService.getAll({ maxPageSize: 50, orderBy: ['scheduledDate asc', 'inspectionid asc'], select: [...] })` plus `skipToken` continuation support)
- **Customer telemetry** (emit only when the user explicitly requested named customer events; omit otherwise) — list each event name, trigger, and approved scalar properties. Example: `OrderSubmitted after successful create; properties: result, duration_ms, item_count`. Never include form values, free text, record titles, personal identifiers, tokens, precise coordinates, nested objects, or complete URLs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

sending result is against the privacy policy of this repo. Not only that the LLM response is not reliable source of durations_ms

- **Related entity fields** (REQUIRED if any UI field on the screen displays data from an entity OTHER than the primary `Data` service's table; OMIT entirely otherwise) — one entry per cross-entity field. The `data-model-architect`'s Step 6a Cross-entity Read Audit reads this block to decide which calculated columns to propose. Mechanical schema:
- **Related entity fields** (REQUIRED if any UI field on the screen displays data from an entity OTHER than the primary `Data` service's table; OMIT entirely otherwise) — one entry per cross-entity field. The `data-model-architect` audits that each field has a supported read path. Mechanical schema:

```yaml
Expand Down
70 changes: 50 additions & 20 deletions plugins/mobile-apps/hooks/run-telemetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,34 +96,64 @@ function withStableDispatchCwd(callback) {
}
}

async function run(mode) {
let payload;
try {
payload = JSON.parse(await readStdin());
} catch {
return;
}

const skillName = invocationFor(mode, payload);
if (!skillName) return;

const invocationCwd = resolveInvocationCwd(payload);
withStableDispatchCwd(() => {
const context = telemetry.createTelemetryContext(payload);
if (context) {
// Builds a hook-driven handler: reads a JSON payload from stdin, resolves the
// tracked skill for `mode`, and emits `skill_started`.
function skillStart(mode) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was also thinking on revamping this, right now telemetry is not specific to skills start/stop. We are building checkpoints to these skills call which will have a universal llm call.

LLM will simply trigger trigger_telemetry(<skill-name>, <checkpoint-name>, <checkpoint-state>)

return {
stdin: true,
handle({ payload }) {
const skillName = invocationFor(mode, payload);
if (!skillName) return;
const context = telemetry.createTelemetryContext(payload);
if (!context) return;
telemetry.emitSkillStarted(
context,
{ skillName, source: mode },
{ cwd: invocationCwd },
{ cwd: resolveInvocationCwd(payload) },
);
},
};
}

// Registry of telemetry modes. Add a new command-driven event by adding one
// entry with `stdin: false` and a `handle`; add a new hook mode with
// `skillStart('<mode>')`. Shared plumbing (stdin read, cwd stabilization,
// fail-open) stays centralized in `run`, so handlers only source input and emit.
const HANDLERS = {
prompt: skillStart('prompt'),
pretool: skillStart('pretool'),
'app-insights-selection': {
stdin: false,
handle({ args: [selection, invocationCwd] }) {
const context = telemetry.createTelemetryContext({});
if (!context) return;
telemetry.emitAppInsightsSelection(context, selection, { cwd: invocationCwd });
},
},
};

async function run(mode, args = []) {
const handler = HANDLERS[mode];
if (!handler) return;

let payload;
if (handler.stdin) {
try {
payload = JSON.parse(await readStdin());
} catch {
return;
}
});
}

withStableDispatchCwd(() => handler.handle({ args, payload }));
}

function start(mode) {
run(mode).catch(() => {}).finally(() => process.exit(0));
function start(mode, args = []) {
run(mode, args).catch(() => {}).finally(() => process.exit(0));
}

if (require.main === module) start(process.argv[2]);
if (require.main === module) {
start(process.argv[2], process.argv.slice(3));
}

module.exports = { start, withStableDispatchCwd };
26 changes: 26 additions & 0 deletions plugins/mobile-apps/scripts/lib/mobile-telemetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,33 @@ function emitSkillStarted(context, invocation, opts = {}) {
return event;
}

// Records the creator's Step 6.8 Application Insights choice as its own usage
// event. It reuses the skill_started common-field allowlist and CS4.0 envelope
// (so the wire shape and privacy guarantees stay identical), then names the
// event `app_insights_selection` and carries the single new datum — a closed
// `enabled`/`disabled` enum — inside the already-approved dynamic `eventInfo`
// object, so no new allowlisted top-level column is required.
function emitAppInsightsSelection(context, selection, opts = {}) {
if (selection !== 'enabled' && selection !== 'disabled') {
throw new TypeError("Application Insights selection must be 'enabled' or 'disabled'.");
}

const skillName = opts.skillName || 'create-mobile-app';
const event = events.buildSkillStarted(
context.eventStreamName,
commonFields(context, { skillName }, opts),
);
Comment on lines +294 to +298
event.data.eventName = 'app_insights_selection';
event.data.eventInfo = {
...(event.data.eventInfo || {}),
appInsightsSelection: selection,
};
dispatch(context, event, opts);
return event;
}

module.exports = {
createTelemetryContext,
emitAppInsightsSelection,
emitSkillStarted,
};
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,43 @@ test('hook dispatch runs outside the caller project and restores its cwd', () =>
assert.equal(process.cwd(), originalCwd);
});

test('runner emits the Application Insights selection event', (t) => {
const context = fixture(t);
const probePath = path.join(context.root, 'probe.json');
const result = spawnSync(
process.execPath,
[
path.join(HOOKS, 'run-telemetry.js'),
'app-insights-selection',
'enabled',
context.projectRoot,
],
{
cwd: context.projectRoot,
encoding: 'utf8',
timeout: 10_000,
env: {
...process.env,
POWER_PLATFORM_SKILLS_CONFIG_DIR: context.configDir,
POWER_PLATFORM_SKILLS_IKEY_JSON: context.ikeyPath,
POWER_PLATFORM_SKILLS_FAKE_HTTPS: probePath,
POWER_PLATFORM_SKILLS_TELEMETRY_MOBILE_APP_OPTOUT: '',
},
},
);

assert.equal(result.status, 0);
const probe = waitForJson(probePath);
assert.ok(probe);
const envelope = JSON.parse(probe.body);
const dimensions = JSON.parse(envelope.data.customDimensions);
assert.equal(envelope.data.event_Name, 'app_insights_selection');
assert.deepEqual(dimensions.eventInfo, {
appInstanceId: null,
appInsightsSelection: 'enabled',
});
});

test('prompt and pretool paths emit independent start signals', (t) => {
const context = fixture(t);
assert.equal(runHook('prompt', {
Expand Down
21 changes: 21 additions & 0 deletions plugins/mobile-apps/scripts/tests/mobile-telemetry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const { spawnSync } = require('node:child_process');

const {
createTelemetryContext,
emitAppInsightsSelection,
emitSkillStarted,
} = require('../lib/mobile-telemetry');
const { ensureAppInstanceId, findAppInstanceId } = require('../lib/app-identity');
Expand Down Expand Up @@ -207,6 +208,26 @@ test('started event is allowlisted and carries no user, tenant, prompt, or path
}
});

test('Application Insights prompt selections emit only the approved choice', (t) => {
const context = contextFor(provisioned);
for (const selection of ['enabled', 'disabled']) {
let captured;
const event = emitAppInsightsSelection(context, selection, {
emit: (value) => { captured = value; },
readAiAgent: () => ({}),
correlationId: 'correlation-1',
cwd: tempProject(t),
});
assert.equal(captured, event);
assert.equal(event.data.eventName, 'app_insights_selection');
assert.equal(event.data.skillName, 'create-mobile-app');
assert.deepEqual(event.data.eventInfo, {
appInstanceId: null,
appInsightsSelection: selection,
});
}
});

test('app instance id is minted once and reused by later skill runs', (t) => {
const project = tempProject(t);
fs.writeFileSync(
Expand Down
Loading
Loading