Skip to content

Commit b20b740

Browse files
amitjoshi438Amit JoshiCopilot
authored
Log Entra ID Object Id in Power Pages telemetry eventInfo (#218)
* Add Entra ID Object Id to Power Pages telemetry eventInfo Parse the 'Entra ID Object Id' line from 'pac auth who' in readPacAuth and attach it to the telemetry event's dynamic eventInfo object as aadObjectId. Update shared source and bundled plugin copies together, refresh tests, and document the field in the telemetry README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stringify eventInfo on the wire for telemetry dispatcher Server-side Interchange/LogAnalytics field mapping flattens data.<key> to a single data_<key> leaf and does not recurse into nested object values, so a raw eventInfo object (e.g. aadObjectId) never produces the data_eventInfo leaf the mapping expects. JSON.stringify eventInfo just for the wire envelope in buildEnvelope() so it lands as a scalar leaf Kusto can read back with parse_json()/todynamic(). The local mirror (events.jsonl) keeps the real object for human readability. Sync plugin's bundled telemetry copy and add dispatcher test coverage asserting the wire body carries eventInfo as a JSON string while the local mirror keeps it as an object. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Trim redundant eventInfo stringify comments in emit-dispatcher.js Both buildEnvelope() and the stdin handler had overlapping explanations of the same wire-vs-mirror eventInfo divergence; condensed to one comment each. Re-synced the plugin's physical copy (no Git symlinks per repo convention) so shared and bundled files stay byte-identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Amit Joshi <amitjoshi@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f4a9cfc commit b20b740

14 files changed

Lines changed: 231 additions & 15 deletions

File tree

plugins/power-pages/hooks/run-skill-pretool-telemetry.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ function readStdin() {
144144
};
145145
if (pacAuth && pacAuth.orgId) fields.orgId = pacAuth.orgId;
146146
if (pacAuth && pacAuth.tenantId) fields.tenantId = pacAuth.tenantId;
147+
if (pacAuth && pacAuth.objectId) fields.eventInfo = { aadObjectId: pacAuth.objectId };
147148
if (agentInfo.aiAgentName) fields.aiAgentName = agentInfo.aiAgentName;
148149
if (agentInfo.aiAgentVersion) fields.aiAgentVersion = agentInfo.aiAgentVersion;
149150
if (agentInfo.pacCliVersion) fields.pacCliVersion = agentInfo.pacCliVersion;

plugins/power-pages/scripts/lib/telemetry/lib/emit-dispatcher.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,24 @@ function sanitizeData(data) {
8585
return filtered;
8686
}
8787

88-
// Build the CS4.0 envelope from a pre-sanitized payload + timestamp. Both are
89-
// computed once in the stdin handler and shared with the local mirror so the
90-
// on-disk record and the wire envelope carry byte-identical `data` and `time`.
88+
// Build the CS4.0 envelope from a pre-sanitized payload + timestamp. `sanitized`
89+
// is shared with the local mirror so `data`/`time` stay byte-identical for
90+
// every field except `eventInfo`: the tenant-side field mapping flattens
91+
// `data.<key>` to a single `data_<key>` leaf and doesn't recurse into nested
92+
// objects, so `eventInfo` is stringified just for the wire to survive that
93+
// flattening. Kusto reads it back with `parse_json()`/`todynamic()`; the
94+
// local mirror keeps the real object for human readability.
9195
function buildEnvelope(eventName, time, sanitized, resolvedIKey, eventStreamName) {
96+
const wireData = { ...sanitized };
97+
if (wireData.eventInfo !== undefined) {
98+
wireData.eventInfo = JSON.stringify(wireData.eventInfo);
99+
}
92100
return {
93101
ver: "4.0",
94102
name: eventStreamName || eventName || "",
95103
time,
96104
iKey: "o:" + String(resolvedIKey || "").split("-")[0],
97-
data: sanitized,
105+
data: wireData,
98106
};
99107
}
100108

@@ -138,8 +146,8 @@ process.stdin.on("end", async () => {
138146
}
139147

140148
// Compute the sanitized payload + timestamp ONCE. The sanitized data is
141-
// exactly what lands in Kusto (its field names ARE the Kusto column names);
142-
// the local mirror and the wire envelope share it so they can never diverge.
149+
// exactly what lands in Kusto (its field names ARE the Kusto column names),
150+
// except `eventInfo` which buildEnvelope() re-serializes for the wire only.
143151
const time = new Date().toISOString();
144152
const sanitized = sanitizeData(event.data);
145153
const localRecord = { time, name: event.name, data: sanitized };

plugins/power-pages/scripts/lib/telemetry/lib/emit-from-prompt.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ function emitSkillStartedFromPrompt(promptText, opts = {}) {
116116
};
117117
if (pacAuth && pacAuth.orgId) fields.orgId = pacAuth.orgId;
118118
if (pacAuth && pacAuth.tenantId) fields.tenantId = pacAuth.tenantId;
119+
if (pacAuth && pacAuth.objectId) fields.eventInfo = { aadObjectId: pacAuth.objectId };
119120
if (agentInfo.aiAgentName) fields.aiAgentName = agentInfo.aiAgentName;
120121
if (agentInfo.aiAgentVersion) fields.aiAgentVersion = agentInfo.aiAgentVersion;
121122
if (agentInfo.pacCliVersion) fields.pacCliVersion = agentInfo.pacCliVersion;

plugins/power-pages/scripts/lib/telemetry/lib/events.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
// or negative values clamp to 0. Sent as number, not string.
1111
// "object" — Kusto column type `dynamic` (JSON). Plain objects and
1212
// arrays pass through; primitives, Date, RegExp, etc. are
13-
// dropped to avoid Kusto type confusion.
13+
// dropped to avoid Kusto type confusion. Validated here as a
14+
// real object; emit-dispatcher.js re-serializes it to a JSON
15+
// string just before it hits the wire (see buildEnvelope),
16+
// so the Kusto side must `parse_json()` / `todynamic()` it.
1417
// "enum:a|b|c" — Kusto column type `string`. Only the listed values are
1518
// accepted; anything else is dropped.
1619
//

plugins/power-pages/scripts/lib/telemetry/lib/pac-auth.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function readPacAuth(opts = {}) {
5959
const tenantId = pickLine(output, "Tenant Id");
6060
const orgId = pickLine(output, "Organization Id");
6161
const cloud = pickLine(output, "Cloud");
62+
const objectId = pickLine(output, "Entra ID Object Id");
6263
if (!tenantId && !orgId) {
6364
cache = null;
6465
return null;
@@ -67,6 +68,7 @@ function readPacAuth(opts = {}) {
6768
orgId: orgId || "",
6869
tenantId: tenantId || "",
6970
cloud: cloud || "",
71+
objectId: objectId || "",
7072
};
7173
return cache;
7274
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"use strict";
2+
3+
// Unit coverage for the bundled telemetry pac-auth copy. The plugin ships a
4+
// physical copy of shared/telemetry/lib/pac-auth.js (no symlink), so this test
5+
// asserts the copy parses `pac auth who` the same way — including the optional
6+
// "Entra ID Object Id" line surfaced as `objectId`. The emit-* hook tests are
7+
// spawn-based integration tests that call real `pac`, so they can't inject a
8+
// fake object id; this is the deterministic seam for that field.
9+
10+
const test = require("node:test");
11+
const assert = require("node:assert/strict");
12+
13+
const pacAuth = require("../lib/telemetry/lib/pac-auth");
14+
15+
const SAMPLE_OUTPUT = `Type: Universal
16+
Cloud: Public
17+
Tenant Id: 11111111-1111-1111-1111-111111111111
18+
Tenant Country: US
19+
User: user@example.com
20+
Entra ID Object Id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
21+
PUID: 10000000ABCDEF01
22+
User Country/Region: US
23+
Token Expires: 2026-05-05T18:00:00Z
24+
Authority: https://login.microsoftonline.com/...
25+
Environment Geo: NorthAmerica
26+
Environment Id: 22222222-2222-2222-2222-222222222222
27+
Environment Type: Sandbox
28+
Organization Id: 33333333-3333-3333-3333-333333333333
29+
Organization Unique Name: contoso
30+
Organization Friendly Name: Contoso
31+
`;
32+
33+
test("parses orgId, tenantId, cloud, and Entra ID objectId", () => {
34+
pacAuth._resetCache();
35+
const result = pacAuth.readPacAuth({ _exec: () => SAMPLE_OUTPUT });
36+
assert.deepEqual(result, {
37+
orgId: "33333333-3333-3333-3333-333333333333",
38+
tenantId: "11111111-1111-1111-1111-111111111111",
39+
cloud: "Public",
40+
objectId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
41+
});
42+
});
43+
44+
test("objectId is '' when the Entra ID Object Id line is missing", () => {
45+
pacAuth._resetCache();
46+
const result = pacAuth.readPacAuth({
47+
_exec: () =>
48+
"Cloud: Public\n" +
49+
"Tenant Id: 11111111-1111-1111-1111-111111111111\n" +
50+
"Organization Id: 33333333-3333-3333-3333-333333333333\n",
51+
});
52+
assert.equal(result.objectId, "");
53+
});
54+
55+
test("returns null when neither Tenant Id nor Organization Id is found", () => {
56+
pacAuth._resetCache();
57+
const result = pacAuth.readPacAuth({
58+
_exec: () => "Type: Universal\nCloud: Public\n",
59+
});
60+
assert.equal(result, null);
61+
});
62+
63+
test("returns null when pac is missing (ENOENT)", () => {
64+
pacAuth._resetCache();
65+
const result = pacAuth.readPacAuth({
66+
_exec: () => {
67+
const e = new Error("spawn pac ENOENT");
68+
e.code = "ENOENT";
69+
throw e;
70+
},
71+
});
72+
assert.equal(result, null);
73+
});
74+
75+
test("caches result across calls (single fork per process)", () => {
76+
pacAuth._resetCache();
77+
let calls = 0;
78+
const exec = () => {
79+
calls++;
80+
return SAMPLE_OUTPUT;
81+
};
82+
pacAuth.readPacAuth({ _exec: exec });
83+
pacAuth.readPacAuth({ _exec: exec });
84+
assert.equal(calls, 1, "second call should hit cache");
85+
});

shared/telemetry/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Every event carries a fixed allowlist enforced by `lib/events.js`. Field names m
8686
**Per-event:**
8787

8888
- `skillName` (on every event)
89-
- `eventInfo` — caller-supplied JSON object (dynamic Kusto column). The caller is responsible for not putting PII in this payload.
89+
- `eventInfo` — caller-supplied JSON object (dynamic Kusto column). The caller is responsible for not putting PII in this payload. Power Pages populates it with `aadObjectId` (the signed-in user's Entra ID / AAD directory object id, parsed from `pac auth who`) when available; the field is omitted when `pac auth who` doesn't surface an object id. On the wire it is sent as a JSON **string** (re-serialized by `emit-dispatcher.js`, not the local mirror) because the tenant-side field mapping flattens `data.<key>` to a single `data_<key>` leaf and does not recurse into nested objects — the Kusto side must `parse_json()` / `todynamic()` it back into a dynamic value.
9090

9191
## What is NEVER sent
9292

shared/telemetry/lib/emit-dispatcher.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,24 @@ function sanitizeData(data) {
8585
return filtered;
8686
}
8787

88-
// Build the CS4.0 envelope from a pre-sanitized payload + timestamp. Both are
89-
// computed once in the stdin handler and shared with the local mirror so the
90-
// on-disk record and the wire envelope carry byte-identical `data` and `time`.
88+
// Build the CS4.0 envelope from a pre-sanitized payload + timestamp. `sanitized`
89+
// is shared with the local mirror so `data`/`time` stay byte-identical for
90+
// every field except `eventInfo`: the tenant-side field mapping flattens
91+
// `data.<key>` to a single `data_<key>` leaf and doesn't recurse into nested
92+
// objects, so `eventInfo` is stringified just for the wire to survive that
93+
// flattening. Kusto reads it back with `parse_json()`/`todynamic()`; the
94+
// local mirror keeps the real object for human readability.
9195
function buildEnvelope(eventName, time, sanitized, resolvedIKey, eventStreamName) {
96+
const wireData = { ...sanitized };
97+
if (wireData.eventInfo !== undefined) {
98+
wireData.eventInfo = JSON.stringify(wireData.eventInfo);
99+
}
92100
return {
93101
ver: "4.0",
94102
name: eventStreamName || eventName || "",
95103
time,
96104
iKey: "o:" + String(resolvedIKey || "").split("-")[0],
97-
data: sanitized,
105+
data: wireData,
98106
};
99107
}
100108

@@ -138,8 +146,8 @@ process.stdin.on("end", async () => {
138146
}
139147

140148
// Compute the sanitized payload + timestamp ONCE. The sanitized data is
141-
// exactly what lands in Kusto (its field names ARE the Kusto column names);
142-
// the local mirror and the wire envelope share it so they can never diverge.
149+
// exactly what lands in Kusto (its field names ARE the Kusto column names),
150+
// except `eventInfo` which buildEnvelope() re-serializes for the wire only.
143151
const time = new Date().toISOString();
144152
const sanitized = sanitizeData(event.data);
145153
const localRecord = { time, name: event.name, data: sanitized };

shared/telemetry/lib/emit-from-prompt.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ function emitSkillStartedFromPrompt(promptText, opts = {}) {
116116
};
117117
if (pacAuth && pacAuth.orgId) fields.orgId = pacAuth.orgId;
118118
if (pacAuth && pacAuth.tenantId) fields.tenantId = pacAuth.tenantId;
119+
if (pacAuth && pacAuth.objectId) fields.eventInfo = { aadObjectId: pacAuth.objectId };
119120
if (agentInfo.aiAgentName) fields.aiAgentName = agentInfo.aiAgentName;
120121
if (agentInfo.aiAgentVersion) fields.aiAgentVersion = agentInfo.aiAgentVersion;
121122
if (agentInfo.pacCliVersion) fields.pacCliVersion = agentInfo.pacCliVersion;

shared/telemetry/lib/events.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
// or negative values clamp to 0. Sent as number, not string.
1111
// "object" — Kusto column type `dynamic` (JSON). Plain objects and
1212
// arrays pass through; primitives, Date, RegExp, etc. are
13-
// dropped to avoid Kusto type confusion.
13+
// dropped to avoid Kusto type confusion. Validated here as a
14+
// real object; emit-dispatcher.js re-serializes it to a JSON
15+
// string just before it hits the wire (see buildEnvelope),
16+
// so the Kusto side must `parse_json()` / `todynamic()` it.
1417
// "enum:a|b|c" — Kusto column type `string`. Only the listed values are
1518
// accepted; anything else is dropped.
1619
//

0 commit comments

Comments
 (0)