@plainconceptsplatform/workflows 0.28.6 → 0.28.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -32,6 +32,9 @@ export const FINDING_FIELDS = [
|
|
|
32
32
|
"firstSeen",
|
|
33
33
|
"lastSeen",
|
|
34
34
|
"frames",
|
|
35
|
+
"topFrames",
|
|
36
|
+
"message",
|
|
37
|
+
"category",
|
|
35
38
|
"fingerprint",
|
|
36
39
|
];
|
|
37
40
|
|
|
@@ -112,6 +115,16 @@ export function ownFrames(details, prefix) {
|
|
|
112
115
|
.slice(0, MAX_FRAMES);
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
export function topFrames(details) {
|
|
119
|
+
const stacks = (Array.isArray(details) ? details : []).flatMap((detail) =>
|
|
120
|
+
Array.isArray(detail?.parsedStack) ? detail.parsedStack : []);
|
|
121
|
+
|
|
122
|
+
return stacks
|
|
123
|
+
.filter((frame) => typeof frame?.method === "string")
|
|
124
|
+
.map((frame) => clip(frame.method))
|
|
125
|
+
.slice(0, MAX_FRAMES);
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
/** Masks everything in SCRUBS. Applied to every string before it is rendered or scanned. */
|
|
116
129
|
export function scrub(value) {
|
|
117
130
|
return SCRUBS.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), value);
|
|
@@ -162,6 +175,9 @@ export function toFindings(rows, { environment, ownCodePrefix = "", minOccurrenc
|
|
|
162
175
|
firstSeen: clip(row.FirstSeen),
|
|
163
176
|
lastSeen: clip(row.LastSeen),
|
|
164
177
|
frames: ownFrames(row.AnyDetails, ownCodePrefix),
|
|
178
|
+
topFrames: topFrames(row.AnyDetails),
|
|
179
|
+
message: clip(row.AnyMessage),
|
|
180
|
+
category: clip(row.AnyCategory),
|
|
165
181
|
fingerprint: fingerprint(clip(row.ProblemId), clip(row.OperationName), environment),
|
|
166
182
|
}))
|
|
167
183
|
.filter((finding) => finding.occurrences >= minOccurrences && finding.operations >= minOperations)
|
|
@@ -177,34 +193,41 @@ export function toFindings(rows, { environment, ownCodePrefix = "", minOccurrenc
|
|
|
177
193
|
*/
|
|
178
194
|
export function render(finding, { environment, lookbackHours, ownCodePrefix = "" }) {
|
|
179
195
|
const marker = `<!-- pcp-app-error: ${finding.fingerprint} -->`;
|
|
180
|
-
const title = `${finding.exceptionType} in ${finding.operationName || finding.roleName || environment}`;
|
|
196
|
+
const title = `${finding.exceptionType} in ${finding.operationName || finding.category || finding.roleName || environment}`;
|
|
181
197
|
|
|
182
198
|
const body = [
|
|
183
199
|
marker,
|
|
184
200
|
`**${finding.occurrences} occurrence(s)** in the last ${lookbackHours}h in \`${environment}\`, across ${finding.operations} operation(s).`,
|
|
185
201
|
"",
|
|
186
202
|
`- Exception: \`${finding.exceptionType}\``,
|
|
203
|
+
finding.message ? `- Message: \`${finding.message}\`` : null,
|
|
187
204
|
`- Operation: \`${finding.operationName || "(none recorded)"}\``,
|
|
205
|
+
finding.category ? `- Source: \`${finding.category}\`` : null,
|
|
188
206
|
`- Role: \`${finding.roleName || "(none recorded)"}\``,
|
|
189
207
|
`- First seen: ${finding.firstSeen}`,
|
|
190
208
|
`- Last seen: ${finding.lastSeen}`,
|
|
191
209
|
finding.frames.length > 0
|
|
192
210
|
? `\n**Our own frames, outermost first**\n\n\`\`\`\n${finding.frames.join("\n")}\n\`\`\``
|
|
193
|
-
:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
211
|
+
: finding.topFrames.length > 0
|
|
212
|
+
? ownCodePrefix
|
|
213
|
+
// The prefix is set and nothing matched: every frame belongs to the framework or to a
|
|
214
|
+
// dependency. Show the top frames anyway, because a framework stack is still better
|
|
215
|
+
// than nothing when the exception is something like a cancelled task raised inside
|
|
216
|
+
// framework code.
|
|
217
|
+
? `\n_No frame in this exception belongs to \`${clip(ownCodePrefix)}\`: all of them are the framework's or a dependency's._\n\n**Top stack frames, outermost first**\n\n\`\`\`\n${finding.topFrames.join("\n")}\n\`\`\``
|
|
218
|
+
: `\n**Top stack frames, outermost first**\n\n\`\`\`\n${finding.topFrames.join("\n")}\n\`\`\``
|
|
219
|
+
: ownCodePrefix
|
|
220
|
+
? `\n_No frame in this exception belongs to \`${clip(ownCodePrefix)}\`: all of them are the framework's or a dependency's. That is usual for an exception thrown inside framework code, such as a cancelled task._`
|
|
221
|
+
// No prefix, so no frame can be recognised as this repository's own and the
|
|
222
|
+
// stack is dropped rather than publishing somebody else's file paths.
|
|
223
|
+
: "\n_No stack frames are shown: this repository has not said which assemblies are its own. Set `OWN_CODE_PREFIX` in the workflow to name them._",
|
|
201
224
|
"",
|
|
202
225
|
"---",
|
|
203
226
|
"",
|
|
204
227
|
"Filed automatically from Application Insights. Nothing here was read by a model, and no",
|
|
205
228
|
"identifier that joins back to a person is included, so the reproduction is still somebody's",
|
|
206
229
|
"to work out. Close this with the `error-accepted` label to stop it being filed again.",
|
|
207
|
-
].join("\n");
|
|
230
|
+
].filter(Boolean).join("\n");
|
|
208
231
|
|
|
209
232
|
return { title, body, marker };
|
|
210
233
|
}
|
|
@@ -47,20 +47,25 @@ let window = ago(${LOOKBACK_HOURS}h);
|
|
|
47
47
|
let thrown =
|
|
48
48
|
AppExceptions
|
|
49
49
|
| where TimeGenerated > window
|
|
50
|
-
| extend Problem = ProblemId, Kind = ExceptionType, Stack = Details
|
|
50
|
+
| extend Problem = ProblemId, Kind = ExceptionType, Stack = Details,
|
|
51
|
+
Msg = outerMessage, Category = "";
|
|
51
52
|
let logged =
|
|
52
53
|
AppTraces
|
|
53
54
|
| where TimeGenerated > window
|
|
54
55
|
| where SeverityLevel >= 3
|
|
55
56
|
| extend Problem = tostring(coalesce(Properties["OriginalFormat"], Message)),
|
|
56
57
|
Kind = tostring(coalesce(Properties["ExceptionType"], "LoggedError")),
|
|
57
|
-
Stack = dynamic([])
|
|
58
|
+
Stack = dynamic([]),
|
|
59
|
+
Msg = Message,
|
|
60
|
+
Category = tostring(coalesce(Properties["SourceContext"], Properties["Category"], ""));
|
|
58
61
|
union thrown, logged
|
|
59
62
|
| summarize Occurrences = sum(ItemCount),
|
|
60
63
|
Operations = dcount(OperationName),
|
|
61
64
|
FirstSeen = min(TimeGenerated),
|
|
62
65
|
LastSeen = max(TimeGenerated),
|
|
63
|
-
AnyDetails = any(Stack)
|
|
66
|
+
AnyDetails = any(Stack),
|
|
67
|
+
AnyMessage = any(Msg),
|
|
68
|
+
AnyCategory = any(Category)
|
|
64
69
|
by ProblemId = Problem, ExceptionType = Kind, OperationName, AppRoleName
|
|
65
70
|
| where Occurrences >= ${MIN_OCCURRENCES}
|
|
66
71
|
| order by Occurrences desc
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
fingerprint,
|
|
15
15
|
leaks,
|
|
16
16
|
ownFrames,
|
|
17
|
+
topFrames,
|
|
17
18
|
prepare,
|
|
18
19
|
render,
|
|
19
20
|
scrub,
|
|
@@ -49,6 +50,8 @@ function row(overrides = {}) {
|
|
|
49
50
|
Operations: 3,
|
|
50
51
|
FirstSeen: "2026-09-13T01:10:00Z",
|
|
51
52
|
LastSeen: "2026-09-13T06:40:00Z",
|
|
53
|
+
AnyMessage: "Sequence contains no elements",
|
|
54
|
+
AnyCategory: "Pliny.Application.Agents.RunExecutor",
|
|
52
55
|
AnyOperationId: "6f1c1b0e9a2f4d55",
|
|
53
56
|
AnyDetails: [
|
|
54
57
|
{
|
|
@@ -113,6 +116,16 @@ check(
|
|
|
113
116
|
|
|
114
117
|
check("a row with no details does not throw", ownFrames(undefined, "Pliny").length === 0);
|
|
115
118
|
|
|
119
|
+
check(
|
|
120
|
+
"topFrames returns frames without prefix filter",
|
|
121
|
+
topFrames(row().AnyDetails).length >= 1);
|
|
122
|
+
|
|
123
|
+
check(
|
|
124
|
+
"topFrames includes framework methods",
|
|
125
|
+
topFrames(row().AnyDetails).includes("Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke"));
|
|
126
|
+
|
|
127
|
+
check("topFrames empty when no details", topFrames(undefined).length === 0);
|
|
128
|
+
|
|
116
129
|
section("Fingerprints");
|
|
117
130
|
|
|
118
131
|
check(
|
|
@@ -158,6 +171,16 @@ check(
|
|
|
158
171
|
"run_id is not a reportable field",
|
|
159
172
|
!FINDING_FIELDS.includes("runId") && !FINDING_FIELDS.includes("run_id"));
|
|
160
173
|
|
|
174
|
+
check(
|
|
175
|
+
"new fields are in the allowlist",
|
|
176
|
+
FINDING_FIELDS.includes("message") && FINDING_FIELDS.includes("category") && FINDING_FIELDS.includes("topFrames"));
|
|
177
|
+
|
|
178
|
+
check(
|
|
179
|
+
"toFindings maps the new fields",
|
|
180
|
+
toFindings([row()], options)[0].message === "Sequence contains no elements" &&
|
|
181
|
+
toFindings([row()], options)[0].category === "Pliny.Application.Agents.RunExecutor" &&
|
|
182
|
+
toFindings([row()], options)[0].topFrames.length >= 1);
|
|
183
|
+
|
|
161
184
|
section("Rendering and the scanner");
|
|
162
185
|
|
|
163
186
|
const rendered = render(toFindings([row()], options)[0], options);
|
|
@@ -175,13 +198,18 @@ check(
|
|
|
175
198
|
"no build-machine path reaches the body",
|
|
176
199
|
!rendered.body.includes("/home/vsts"));
|
|
177
200
|
|
|
178
|
-
// Two ways to have no frames, and they ask the reader for different things. Pliny-Bot #191 was
|
|
201
|
+
// Two ways to have no own frames, and they ask the reader for different things. Pliny-Bot #191 was
|
|
179
202
|
// filed with OWN_CODE_PREFIX set to "Pliny" and still said the repository had not said which
|
|
180
203
|
// assemblies were its own -- it sent a reader to configure a knob that had been set the day
|
|
181
204
|
// before. Only the unconfigured case was covered here, which is why the wording survived.
|
|
182
205
|
check(
|
|
183
|
-
"no prefix configured
|
|
206
|
+
"no prefix configured but top frames exist shows top frames",
|
|
184
207
|
render(toFindings([row()], { ...options, ownCodePrefix: "" })[0], { ...options, ownCodePrefix: "" })
|
|
208
|
+
.body.includes("Top stack frames"));
|
|
209
|
+
|
|
210
|
+
check(
|
|
211
|
+
"no prefix configured and no frames at all says which knob to set",
|
|
212
|
+
render(toFindings([row({ AnyDetails: [] })], { ...options, ownCodePrefix: "" })[0], { ...options, ownCodePrefix: "" })
|
|
185
213
|
.body.includes("has not said which assemblies are its own"));
|
|
186
214
|
|
|
187
215
|
check(
|
|
@@ -194,6 +222,28 @@ check(
|
|
|
194
222
|
render(toFindings([row()], { ...options, ownCodePrefix: "NoSuchAssembly" })[0], { ...options, ownCodePrefix: "NoSuchAssembly" })
|
|
195
223
|
.body.includes("No frame in this exception belongs to `NoSuchAssembly`"));
|
|
196
224
|
|
|
225
|
+
check("body includes the message", rendered.body.includes("Message: `Sequence contains no elements`"));
|
|
226
|
+
|
|
227
|
+
check("body includes the source", rendered.body.includes("Source: `Pliny.Application.Agents.RunExecutor`"));
|
|
228
|
+
|
|
229
|
+
check(
|
|
230
|
+
"title uses category as fallback",
|
|
231
|
+
render(toFindings([row({ OperationName: "" })], options)[0], options).title ===
|
|
232
|
+
"System.InvalidOperationException in Pliny.Application.Agents.RunExecutor");
|
|
233
|
+
|
|
234
|
+
check("body shows own frames when available", rendered.body.includes("Our own frames"));
|
|
235
|
+
|
|
236
|
+
check(
|
|
237
|
+
"body shows top frames when no own frames",
|
|
238
|
+
render(toFindings([row({
|
|
239
|
+
ProblemId: "System.Threading.Tasks.TaskCanceledException",
|
|
240
|
+
ExceptionType: "System.Threading.Tasks.TaskCanceledException",
|
|
241
|
+
AnyDetails: [{ parsedStack: [{ method: "System.Net.Http.HttpClient.SendAsync" }] }],
|
|
242
|
+
AnyMessage: "A task was canceled.",
|
|
243
|
+
})], { ...options, ownCodePrefix: "Pliny" })[0],
|
|
244
|
+
{ ...options, ownCodePrefix: "Pliny" })
|
|
245
|
+
.body.includes("Top stack frames"));
|
|
246
|
+
|
|
197
247
|
section("Fail-closed, but not fail-dead");
|
|
198
248
|
|
|
199
249
|
const mixed = prepare(
|
|
@@ -152,6 +152,17 @@ post-steps:
|
|
|
152
152
|
npm install -g "agent-browser@${{ env.AGENT_BROWSER_VERSION }}"
|
|
153
153
|
# The npm package alone cannot drive a browser: the Chromium binary is a separate
|
|
154
154
|
# download, and a self-hosted Linux runner needs the system dependencies too.
|
|
155
|
+
# --with-deps is agent-browser's best effort but misses libraries on ubuntu-24.04.
|
|
156
|
+
chrome_dir=$(agent-browser path 2>/dev/null | grep -oE '/[^ ]+chrome[^ ]*' | head -1 || true)
|
|
157
|
+
if [ -n "$chrome_dir" ] && [ -d "$(dirname "$chrome_dir")/.." ]; then
|
|
158
|
+
ldd "$chrome_dir" 2>/dev/null | grep "not found" || true
|
|
159
|
+
fi
|
|
160
|
+
# Install the known missing Chromium dependencies on Debian/ubuntu runners.
|
|
161
|
+
# agent-browser install --with-deps may leave these out depending on the runner image.
|
|
162
|
+
sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends \
|
|
163
|
+
libnss3 libatk1.0-0 libatk-bridge2.0-0 libdrm2 libgbm1 \
|
|
164
|
+
libasound2t64 libxkbcommon0 libcairo2 libpango-1.0-0 libxcomposite1 \
|
|
165
|
+
libxdamage1 libxfixes3 libxrandr2 2>/dev/null || true
|
|
155
166
|
agent-browser install --with-deps
|
|
156
167
|
agent-browser --version
|
|
157
168
|
|
|
@@ -205,6 +216,9 @@ post-steps:
|
|
|
205
216
|
echo "Found $waypoint_count waypoints"
|
|
206
217
|
captured=0
|
|
207
218
|
i=0
|
|
219
|
+
# agent-browser uses Chromium which needs --no-sandbox on CI runners where
|
|
220
|
+
# there is no SYS_ADMIN capability. Set it once via the config file.
|
|
221
|
+
export AGENT_BROWSER_ARGS="${AGENT_BROWSER_ARGS:-} --no-sandbox"
|
|
208
222
|
while [ "$i" -lt "$waypoint_count" ]; do
|
|
209
223
|
i=$((i + 1))
|
|
210
224
|
url=$(jq -r ".waypoints[$((i - 1))].url" "$PLAN_FILE")
|
|
@@ -224,6 +238,10 @@ post-steps:
|
|
|
224
238
|
captured=$((captured + 1))
|
|
225
239
|
else
|
|
226
240
|
echo "::warning::waypoint $i: screenshot failed"
|
|
241
|
+
# Diagnostic: show what's in the output dir and any browser errors
|
|
242
|
+
ls -la "$OUTPUT_DIR/" 2>/dev/null || true
|
|
243
|
+
echo "--- agent-browser debug ---"
|
|
244
|
+
agent-browser screenshot "$OUTPUT_DIR/debug-$i.png" --full 2>&1 || true
|
|
227
245
|
fi
|
|
228
246
|
done
|
|
229
247
|
echo "captured=$([ "$captured" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
@@ -233,7 +251,7 @@ post-steps:
|
|
|
233
251
|
# instead (app never started, no plan, or every waypoint failed). `always()` because it
|
|
234
252
|
# must run even when the capture step above warned its way to the end.
|
|
235
253
|
- name: Attach screenshots to the issue
|
|
236
|
-
if: always() &&
|
|
254
|
+
if: always() && env.VISUAL_VERIFY_ENABLED == 'true' && env.VISUAL_VERIFY_START_COMMAND != ''
|
|
237
255
|
uses: ./.github/actions/attach-screenshots
|
|
238
256
|
with:
|
|
239
257
|
token: ${{ github.token }}
|