Repository navigation
feat: Recover schema-less payloads and offer TDH's display strings - #85
Conversation
|
Holding this one. Two blockers before the content is even the question. 1. This PR has never been tested by CI. Not once.
That is worth fixing in the workflow independently of this PR, because the failure mode is silent: a stacked PR looks unremarkable in the UI while being completely unverified. Adding 2. Blocked behind #84, which is red. Its two nested-struct tests fail on the runner, and my read is that the fixture's provider has no manifest on Windows Server rather than that the code is wrong — details in that PR. This cannot be evaluated on its merits until the base is green. On the content, from reading rather than running, because I have no Windows and this has no CI: Recovering the payload for schema-less events is clearly right. Reading the bytes and dropping them was the worst of both worlds, and confining Typed values staying the default is the right call, and I agree The design question I would want settled before merge: Not necessarily a blocker — a global is defensible for something set once at startup — but it is a public API that will be awkward to narrow later, so it deserves a deliberate decision rather than arriving as an implementation detail. What made you choose global over per-session? Once #84 is green and this is retargeted at |
5b0ec2f to
dfd0ee2
Compare
The last two gaps #72 listed for the TDH parser. **Events TDH cannot describe.** `TdhGetEventInformation` has no schema for WPP events without a matching TMF, nor for manifest providers whose manifest is not installed on this machine. Those events came back with an empty property dict and nothing else: their payload was read and then dropped. There are no names to report it under, so it cannot become properties -- keep the bytes instead. `EtwEvent.raw_data` has existed all along but was never filled in, the comment in session.rs explaining that ferrisetw keeps its `user_buffer` private. The record handed to TDH carries the same bytes and this module already holds it, so read `UserData` from there. Only for events with no schema: carrying a second copy of every decoded event's data would cost memory for nothing. `set_wpp_tmf_search_path` passes TDH a TDH_CONTEXT_WPP_TMFSEARCHPATH so a caller holding .tmf files can have WPP events decoded properly rather than falling back to bytes. Unset by default, in which case TDH is passed no context at all, exactly as before. **TDH's display strings.** #72 asked whether typed values or `TdhFormatProperty`'s strings should be the default and whether the other should be available. Typed values stay the default -- the Parquet/Arrow exporters need them -- and the strings become an opt-in second map, `formatted_properties`, behind `set_property_formatting`. Off by default, so the normal path pays nothing. They earn their place by expressing what typed values cannot, because TDH applies the manifest's value maps and output types: DynamicAddress typed=1 formatted='dynamic ' Address typed=b'\x02\x00\x00\x00\xc0\xa82\x01...' formatted='192.168.50.1' QueryBlob typed=2525199111760 formatted='0x24BF1989650' `TdhFormatProperty` is given one property's bytes, already isolated by `TdhGetProperty`, rather than the event payload with running offsets. That walk is what the rest of this module avoids on purpose, and skipping it costs nothing because TDH has already located the value. Arrays and structs are left out of the string view: `TdhFormatProperty` renders a single value, so handing it an array would silently describe only the first element -- a 176-byte TimeZoneInformation rendered as "228". `properties` reports those in full. The TMF search path is not verified end to end. This machine has no .tmf files and none of the WPP tooling that produces them. What is checked is that the context reaches TDH, that setting a path leaves all other decoding byte-identical, and that a path matching nothing is harmless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn
The existing fixtures cannot exercise the schema-less path: everything in sample.etl and nested_struct.etl decodes cleanly, which is the whole point of them. Add no_schema.etl -- the capture that used to be sample.etl before #76 replaced it. It was never a good property-parsing fixture, holding unrelated telemetry and no Kernel-Process events at all, but that is exactly what makes it the right one here: 73 of its 149 events have no schema TDH can find. Tests assert the payload survives for those, that decoded events do not also carry a redundant copy, that display strings appear only when switched on and leave the typed values untouched, and that a TMF search path matching nothing leaves all other decoding byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn
f089361 to
036723e
Compare
dfd0ee2 to
53c011c
Compare
|
Heads-up on CI for this one: it will not run until #84 merges.
Rebased onto the current Worth noting the skip count is now genuinely zero locally, because #82 landed and pandas is installed here; CI will show 2 skipped for the pandas-gated |
* feat: Decode nested structure properties Struct-typed properties were skipped outright, so their contents were unreachable from Python. On this machine 1512 properties across 904 providers are declared as structs, and a single Kernel-Processor-Power core-parking event carries ten of them, all previously absent from the property dict. TDH addresses a struct member with a chain of PROPERTY_DATA_DESCRIPTORs -- the struct with the index of the instance, then the member -- so `property_bytes` grows into `property_bytes_at`, taking the whole path. Struct values are read recursively into `EventValue::Struct`, and a struct that is itself an array into an `Array` of them. Recursion is capped by MAX_STRUCT_DEPTH: a schema whose members pointed back at an enclosing struct would otherwise not terminate. Two supporting changes fall out of this. `read_layout` now walks all `PropertyCount` properties rather than only the top-level ones, because struct members live past `TopLevelPropertyCount` and `structType` addresses them by their index into that same array. Only the top-level entries become keys of the returned map; the rest are reached through the struct that owns them. Keeping that vector positionally aligned with `EventPropertyInfoArray` also fixes #81. A property with no name used to be dropped from the vector, which shifted every later index, so `PropertyParamCount` resolved its count from the wrong sibling. Unnamed properties now occupy their slot as a placeholder that is skipped when values are read. The struct work needs this alignment to be correct at all, so it is not separable. Verified against tracerpt on live Kernel-Processor-Power events. For the same core-parking event tracerpt reports Group {Number 0, Affinity 0xFFFF} and OldPark/NewPark/NewSoftPark {Number 0, Affinity 0x50C0}, and those are exactly the values that now come through. For an array of structs -- HardwareTable, 64 elements of {LogicalProcessorIndex, WorkLoadClassIndex, PerformanceClass, EfficiencyClass} -- every element matches too, and unlike tracerpt, which flattens all 64 into one repeated list because XML cannot express the grouping, the elements stay grouped. Closes #81 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn * test: Cover nested structure decoding with a fixture that has some sample.etl cannot exercise the struct path: Microsoft-Windows-Kernel-Process declares no struct-typed property, so the feature would have had no automated coverage at all and would only ever have been checked by hand on a live session. Add nested_struct.etl, 40 KB of Kernel-Processor-Power core-parking events carrying seventeen distinct struct properties, plus the script that produces it. The Algorithm keyword (0x20) is what selects those events; wider masks add hundreds of KB of unrelated telemetry without adding a struct, which is what keeps the fixture the same order of size as sample.etl. The tests assert the members are reachable, that they do not also leak in beside their struct at the top level, that structs survive into to_dict(), and that nested values are Python-native recursively. Arrays of structs are not in the fixture. The events carrying them fire rarely enough that capturing one reliably would have meant a multi-megabyte file; that case is covered by the bounds tests in tdh.rs and was verified by hand against tracerpt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn * test: Make the nested-struct fixture decode on machines other than mine CI failed on all five Python versions: the fixture produced its events, but not one struct-valued property, so both struct assertions fired. The cause is the fixture, not the parser. It was a capture of Kernel-Processor-Power, a manifest provider, and decoding a manifest event needs that manifest installed *and of a matching version*. It decoded on the machine that recorded it and yielded nothing on the CI runner's different Windows build. A fixture that only decodes where it was made is worse than no fixture, because it looks like coverage. Record from a TraceLogging provider defined in the generator script instead. Such events embed their own schema, so the ETL decodes identically anywhere, which is a property of the format rather than something to hope for. Windows ships the C# compiler the script uses, so this still needs nothing beyond a stock machine, and the fixture drops from 40 KB to 16 KB. Each event now carries two { Number, Affinity } structs with known values, so the tests can assert the contents outright rather than just their shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn * fix: Drop the length prefix from counted strings and blobs TDH returns the two-byte count as part of the value for the counted in-types, so it was being decoded as content. The new fixture's `Label` holds "core-state" and came back as "\u{14}core-state", the `\u{14}` being its own 20-byte length. The same was visible earlier in a TraceLogging capture, where "hello-from-32bit" read as " hello-from-32bit" -- the leading space was the 32-byte count, quiet enough to miss. Affects win:CountedString, win:CountedAnsiString, win:CountedBinary and their MOF equivalents. The prefix is only removed when it actually describes the rest of the buffer. Stripping unconditionally would eat the first character of any value that TDH had already stripped, and this way a provider or TDH version that behaves differently loses nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn * feat: Recover schema-less payloads and offer TDH's display strings (#85) * feat: Recover schema-less payloads and offer TDH's display strings The last two gaps #72 listed for the TDH parser. **Events TDH cannot describe.** `TdhGetEventInformation` has no schema for WPP events without a matching TMF, nor for manifest providers whose manifest is not installed on this machine. Those events came back with an empty property dict and nothing else: their payload was read and then dropped. There are no names to report it under, so it cannot become properties -- keep the bytes instead. `EtwEvent.raw_data` has existed all along but was never filled in, the comment in session.rs explaining that ferrisetw keeps its `user_buffer` private. The record handed to TDH carries the same bytes and this module already holds it, so read `UserData` from there. Only for events with no schema: carrying a second copy of every decoded event's data would cost memory for nothing. `set_wpp_tmf_search_path` passes TDH a TDH_CONTEXT_WPP_TMFSEARCHPATH so a caller holding .tmf files can have WPP events decoded properly rather than falling back to bytes. Unset by default, in which case TDH is passed no context at all, exactly as before. **TDH's display strings.** #72 asked whether typed values or `TdhFormatProperty`'s strings should be the default and whether the other should be available. Typed values stay the default -- the Parquet/Arrow exporters need them -- and the strings become an opt-in second map, `formatted_properties`, behind `set_property_formatting`. Off by default, so the normal path pays nothing. They earn their place by expressing what typed values cannot, because TDH applies the manifest's value maps and output types: DynamicAddress typed=1 formatted='dynamic ' Address typed=b'\x02\x00\x00\x00\xc0\xa82\x01...' formatted='192.168.50.1' QueryBlob typed=2525199111760 formatted='0x24BF1989650' `TdhFormatProperty` is given one property's bytes, already isolated by `TdhGetProperty`, rather than the event payload with running offsets. That walk is what the rest of this module avoids on purpose, and skipping it costs nothing because TDH has already located the value. Arrays and structs are left out of the string view: `TdhFormatProperty` renders a single value, so handing it an array would silently describe only the first element -- a 176-byte TimeZoneInformation rendered as "228". `properties` reports those in full. The TMF search path is not verified end to end. This machine has no .tmf files and none of the WPP tooling that produces them. What is checked is that the context reaches TDH, that setting a path leaves all other decoding byte-identical, and that a path matching nothing is harmless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn * test: Cover schema-less payloads, display strings, and the TMF path The existing fixtures cannot exercise the schema-less path: everything in sample.etl and nested_struct.etl decodes cleanly, which is the whole point of them. Add no_schema.etl -- the capture that used to be sample.etl before #76 replaced it. It was never a good property-parsing fixture, holding unrelated telemetry and no Kernel-Process events at all, but that is exactly what makes it the right one here: 73 of its 149 events have no schema TDH can find. Tests assert the payload survives for those, that decoded events do not also carry a redundant copy, that display strings appear only when switched on and leave the typed values untouched, and that a TMF search path matching nothing leaves all other decoding byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Actually decode WPP events, through a decoding handle The TMF search path added in #85 never worked. It was reasoned from the documentation and only checked for not disturbing anything, which it did not, because it did nothing at all. Building a real WPP provider and capturing its events showed why. `TdhGetEventInformation` and `TdhGetProperty` accept a `TDH_CONTEXT` array and ignore it for WPP. Verified against a live capture: the TMF search path, a single TMF file, both together, a PDB path, and the TRACE_FORMAT_SEARCH_PATH environment variable all leave `FormattedString` reading "No Format Information found", while tracefmt decodes the same trace from the same TMF directory without complaint. Format information only reaches TDH through a decoding handle: `TdhOpenDecodingHandle`, then `TdhSetDecodingParameter` for each source, then `TdhGetWppMessage`. All three source kinds work that way, so all three are offered -- and `set_wpp_pdb_path` is the pick of them, since TDH reads the format information straight out of the PDB with no `tracepdb` step. Two things only real events could have shown: `TdhGetWppMessage` reports ERROR_SUCCESS from its sizing call, not ERROR_INSUFFICIENT_BUFFER as the rest of TDH does. Treating the usual protocol as the only valid one turned a working decode back into "no format information", and cost an hour of looking in the wrong place. It also returns a message for events that are not WPP at all, so a `FormattedString` began appearing on Kernel-Process events as soon as any source was configured. The layout now records `DecodingSource`, and only events TDH decodes as WPP are asked. Decoded messages replace the placeholder `FormattedString` rather than landing somewhere new, so there is one place to look instead of a property that says "No Format Information found" beside a field holding the answer. Nothing is asked of TDH at all until a source is set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn * test: Cover WPP decoding with a real WPP capture and its TMF Nothing in the repository could exercise WPP: no fixture contained a WPP event, and none of the machines involved had a `.tmf`. That is precisely why #85 shipped a WPP feature that did not work. Add `wpp.etl`, three events from a purpose-built WPP provider, together with the `.tmf` that decodes them. Both are needed -- a WPP capture without its format information is undecodable by construction -- and both are small (8 KB and 344 bytes). The expected strings in the test are what tracefmt, Microsoft's own formatter, produces from the same pair, so the tests are pinned to an independent answer rather than to our own output. The generator script is committed for reproducibility but, unlike the other fixture scripts, cannot run on a bare machine: it needs `tracewpp` and `tracepdb` from the Windows SDK and `cl` from the MSVC build tools. It ends by running tracefmt over what it produced, so the expected values can be re-derived rather than trusted. The tests assert the placeholder appears without a source, the real messages appear with one, that removing the source goes back to the placeholder, and that a source matching nothing leaves every other event's properties byte-identical -- the last of which caught the `FormattedString` leaking onto non-WPP events. The PDB route is not covered here: shipping a PDB as a fixture to test one extra TDH_CONTEXT value is not worth its size. It shares the code path and was verified by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn * docs: Document event property decoding, and cover the PDB route in CI Two loose ends from the WPP work. **Documentation.** Nothing in docs/ described property decoding at all -- not arrays, not nested structures, not raw payloads, and not WPP. Add an Event Properties page covering the lot, with `set_wpp_pdb_path` given the prominence it earns: it is the only route that needs no SDK tooling, and it was previously discoverable only by reading the API. The page also records that WPP derives a trace GUID from the source file's full path, which is not obvious and bites anyone trying to decode a capture with a PDB built elsewhere. **CI.** The committed wpp.etl covers the two TMF routes but cannot cover the PDB one, for exactly that reason: a PDB built anywhere other than where the capture was made has a different trace GUID. Committing a PDB instead is not worth it -- ours is 7 MB, which would be the largest file in the repository by two orders of magnitude, to cover one extra TDH context value. So the new job builds the provider and captures fresh events on the runner, where the GUIDs match by construction, and checks all three routes. Both the Windows SDK (tracewpp, tracepdb, tracefmt) and MSVC are already on windows-latest, so this needs nothing installed. `create_wpp_etl.ps1` gained -OutputDir and -IncludePdb so CI can reuse it, and the provider source moved out of a here-string into wpp_provider/wpptest.c so both the fixture and CI build the same file. The build has to happen in that fixed directory rather than a temp one, or the committed .tmf would stop matching the committed capture; its artifacts are gitignored. The verification is a script rather than a pytest module: it depends on artifacts only CI produces, and expressing that as a test which skips unless some environment variables are set is precisely the kind of quiet skip that let the first attempt at WPP ship broken. It exits non-zero instead. The fixture is regenerated, since moving the build changed the trace GUID. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
The last two things #72 asked for. Stacked on #84 — base is
feat/72-nested-structs, so review that first; GitHub will retarget this tomainwhen #84 merges.With this, every item in #72's proposed approach is either done or has a stated reason it cannot be.
Changes
1. Events TDH cannot describe keep their payload
TdhGetEventInformationhas no schema for WPP without a matching TMF, nor for a manifest provider whose manifest is not installed here. Those events came back with an empty dict and nothing else — the payload was read, then dropped.There are no names to report it under, so it cannot become properties. Keep the bytes instead.
EtwEvent.raw_datahas existed all along but was never populated, the comment insession.rsexplaining that ferrisetw keeps itsuser_bufferprivate — but the record handed to TDH carries the same bytes and this module already holds it.Only for schema-less events; a second copy of every decoded event's data would cost memory for nothing.
Also
set_wpp_tmf_search_path, passing TDH aTDH_CONTEXT_WPP_TMFSEARCHPATHso a caller who has.tmffiles gets WPP decoded properly instead of falling back to bytes. Unset by default, in which case TDH is passed no context at all.2. TDH's display strings, opt-in
#72 asked whether typed values or
TdhFormatProperty's strings should be the default and whether the other should be available. Typed values stay the default — the Parquet/Arrow exporters need them — and the strings become a second map,formatted_properties, behindset_property_formatting. Off by default, so the normal path pays nothing.TdhFormatPropertyis given one property's bytes, already isolated byTdhGetProperty, rather than the event payload with running offsets. That walk is what this module avoids on purpose, and skipping it costs nothing because TDH has already located the value.Related Issue
Advances #72 to completion — see the closing section.
Test Plan
Added/Modified Tests
tdh.rs(formatting default/toggle, TMF path round-trip, no context when unset).test_etl_reader.pyover a newno_schema.etlfixture.The existing fixtures cannot exercise the schema-less path — everything in
sample.etlandnested_struct.etldecodes cleanly, which is the point of them.no_schema.etlis the capture that used to besample.etlbefore #76 replaced it. It was never a good property-parsing fixture (unrelated telemetry, no Kernel-Process events at all), and that is exactly what makes it right here: 73 of its 149 events have no schema TDH can find.Test Results
Payload recovery, on that fixture:
77 KB that used to be discarded, and plainly the real thing — UTF-16
C:\Windows\syste...,\Device\Harddisk....Display strings, on a live DNS-Client capture. These are the properties where the string differs from
str()of the typed value, i.e. what typed values cannot express:A value map resolved (
1→dynamic), a rawSOCKADDRrendered as an IP, and pointers in hex.One bug caught while checking this rather than after:
TdhFormatPropertyrenders a single value, so handing it an array silently described only the first element — a 176-byteTimeZoneInformationcame out as"228", the same failure mode as the array bug in #84. Arrays and structs are now excluded from the string view;propertiesreports them in full.TMF plumbing:
Suite:
Checklist
Where #72 stands after this
EventValuevariants for SID / FILETIME / GUID / binary / pointerMSNT_SystemTracedecodes fullyPropertyParamLength/PropertyParamCountTdhFormatPropertystrings, other opt-inThe one caveat, stated plainly. The TMF search path is not verified end to end. This machine has no
.tmffiles and none of the WPP tooling that produces them (tracefmt,tracepdb,tracevieware all absent, and public Microsoft PDBs do not carry WPP data). What is verified is that the context reaches TDH, that setting a path leaves all other decoding byte-identical, and that a path matching nothing is harmless. Actual WPP decoding needs someone with TMFs to confirm. I would rather say so than let it look verified.Two smaller things also not done, both from #84's notes:
Kernel-Processor-Powerevent 181 fired once in a 5-second capture and never in ~20 shorter ones;Kernel-Pep,Kernel-Power,Kernel-General,Direct3D12produced none), and .NET'sEventSourcerefuses to serialise an array of structs at all — it throwsTargetInvocationExceptionand writes anEventSourceExceptionstring instead of the event. Covered by unit tests and the tracerpt comparison in feat: Decode nested structure properties #84.🤖 Generated with Claude Code
https://claude.ai/code/session_01HhPcm483PrDmuQEUSALBFn