Skip to content

Describe the usage sharing record exactly, as the .NET packages write it - #65

Closed
jwrosewell wants to merge 3 commits into
rcfrom
docs/usage-sharing-record
Closed

jwrosewell wants to merge 3 commits into
rcfrom
docs/usage-sharing-record

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

The usage sharing record on usage-sharing-element.md is changed to the record the .NET packages write today, described completely enough for any language to write the same bytes. Every implementation then moves to it, each in its own issue.

Who asked for this, and what review must check

James Rosewell asked for this work on 3 November 2026 ("Work on that"). He has not seen the text. The review must therefore check that the page records the bytes .NET actually writes, and not what the .NET source appears to say. The three checks this description listed before it left draft were run on 9 November against 51degrees-dotnet rc at 9bf43bc0, and their results are below.

Merge order

Merge this after #56 (how a message is filled, from the minimum to twice it), and after #97 (session tracking, back-off and repeat interval) if #97 is adopted. Whether #97 is adopted is still a question for James, so this page does not depend on it. It says nothing about the back-off, the repeat interval or the session cookie, and it refers to the shared Evidence rules for which values are shared, whatever #97 adds to them. Test merges with git merge-tree and in a local branch showed this branch merging cleanly with rc alone, with #56 alone, with #97 alone, and after #56 and then #97 in that order.

Base

This draft was opened into main. Only rc merges into main, so rc at 4da1891 was merged into the branch (no rebase and no force push) and the base is now rc. The conflict was in the Processing section, which drafts #89 and #99 changed on rc, with #64 and #71 changing its links. The merge (c86b057) kept this draft's record text in place of rc's old list, and kept rc's link to the .NET BuildAndSendXml method. The diff against rc is this draft's own change, in usage-sharing-element.md alone.

What changes in the Processing section

  1. The document is UTF-8 with a byte order mark, an XML declaration and no white space between elements.
  2. ClientIP follows DateSent, and SessionId, Sequence, DateSent and ClientIP are always written, an empty one as <SessionId />. They hold the values found for query.session-id, query.sequence and server.client-ip.
  3. Each value shared is an element named by its prefix as the key has it, with the field in its own case as Name and the value in CDATA, grouped by prefix in the order the values were found. A key with no prefix is an element named by the whole key.
  4. <BadSchema>true</BadSchema> and \x with four lowercase hex digits replace the escaped attribute, and the [TRUNCATED BY USAGE SHARING] prefix at 10,000 UTF-16 code units replaces the truncated attribute.
  5. DateSent, Sequence, FlowElement and ServerIP are defined exactly. ServerIP skips 127.0.0.1 and ::1, as .NET does.
  6. The CDATA split and the escaping in attributes and text are stated, with an example.
  7. The Java link for the old attributes goes, with the attributes.

The batching paragraph is left word for word as rc has it, so #56 changes it alone.

What this update changed (5119a09)

  1. The Evidence entries row. It said "The accepted Evidence decides which are shared". Since Usage sharing accepts no Evidence, and reads the web request instead #99 merged on 9 November, usage sharing accepts no Evidence, and rc says "The Evidence that is shared is what the element finds, as accepted Evidence describes, less what the shared Evidence rules leave out." The row now says the same, and SessionId, Sequence and ClientIP take the value found for their key, from the Evidence or the web request, as Usage sharing accepts no Evidence, and reads the web request instead #99 describes.
  2. The opening sentence said "the same Evidence gives the same bytes". Check 2 below shows the order of the values decides the order of the entries, so it now says "the same values, found in the same order".
  3. A key that starts with . was said to be written as an element named by the whole key. .NET fails the whole message for such a key (check 1, case H), so the page no longer says it is written. What such a key should do is left for review.
  4. Grouping by prefix now says prefixes are grouped as the key spells them, so header and Header are two groups, which is what .NET writes (check 1, case E).
  5. The batching paragraph went back to rc's words, as above.

The .NET checks

A console program referenced pipeline/Engines.FiftyOne and pipeline/Web from 51degrees-dotnet rc at 9bf43bc0 as projects (-p:FiftyOneReferences=Project), built a Pipeline holding only the ShareUsageElement with a minimum of one entry per message, and captured the body it posted through an HttpClient handler, then decompressed it. Nothing was sent to the network.

Check 1, one whole document compared byte for byte. Passed.

For the page's example (header.User-Agent = Mozilla/5.0, header.X-Test = a]]>b, query.51D_Test = x, U+0001, y), .NET sent 661 bytes, which decompressed to this, with U+FEFF standing for the byte order mark.

<?xml version="1.0" encoding="utf-8"?><Devices><Device><SessionId /><Sequence>0</Sequence><DateSent>2026-11-09T13:57:58</DateSent><ClientIP /><Version>4.5.0.0</Version><Product>Pipeline</Product><FlowElement>FiftyOne.Pipeline.Engines.FiftyOne.FlowElements.ShareUsageElement</FlowElement><Language>dotnet</Language><LanguageVersion>10.0.14</LanguageVersion><ServerIP>192.168.3.74</ServerIP><Platform>Microsoft Windows NT 10.0.26200.0</Platform><header Name="User-Agent"><![CDATA[Mozilla/5.0]]></header><header Name="X-Test"><![CDATA[a]]]]><![CDATA[>b]]></header><query Name="51D_Test"><![CDATA[x\x0001y]]></query><BadSchema>true</BadSchema></Device></Devices>

The first three bytes are EF BB BF. With DateSent set to the page's value and Version to Platform replaced by ..., the bytes equal the page's example, with the <Devices> root and the declaration around it, byte for byte.

Further cases on the same build, each matching the page unless it says otherwise.

Case Evidence What .NET wrote
B Query.Session-Id = abc, QUERY.SEQUENCE = 2, Server.Client-IP = 192.0.2.1 <SessionId>abc</SessionId><Sequence>2</Sequence> and <ClientIP>192.0.2.1</ClientIP>, none repeated as an entry, so the keys match without regard to case
C query.sequence = x <Sequence>0</Sequence>
D 10,000 and 10,001 code units the first written as it is, the second as [TRUNCATED BY USAGE SHARING] and its first 10,000 code units, 10,029 in all
E header.B, query.51D_X, header.A, cookie.51D_C, Header.Mixed header B then A, then query, then cookie, then a separate <Header Name="Mixed"> group
F tab, line feed and carriage return in a value, U+0002 in a name, &<>"' in a value the three written as they are inside CDATA, the name as X\x0002Name with BadSchema, and &<>"' as they are inside CDATA
G share all Evidence, nodot = a&b<c>d"e <nodot>a&amp;b&lt;c&gt;d"e</nodot>
H share all Evidence, .dot = w no document sent, and "Unexpected failure while sharing usage data" logged with ArgumentException: Invalid name character in '.dot'
I an emoji (a surrogate pair) and U+0001 in one value no document sent, with OverflowException: Value was either too large or too small for an unsigned byte logged
J an emoji alone written as it is
K U+FFFE in a value no document sent, with the same OverflowException
L U+007F and U+0085 written as they are

Cases H, I and K are .NET failures that lose the whole message, not just the entry. I and K come from EncodeInvalidXMLChars, which replaces every character XmlConvert.IsXmlChar refuses, being U+0000 to U+001F but also each half of a surrogate pair and U+FFFE and U+FFFF, and converts each with Convert.ToByte, which overflows above U+00FF (ShareUsageBase.cs lines 1130 to 1164). The page describes only the replacement .NET completes, U+0000 to U+001F. What a language should do with these three cases is a question for review. Each one loses every record in the message, so each is a .NET defect whatever the page comes to say.

Check 2, the order of the Evidence from the web integration. The order is the web framework's, not the order on the wire.

The same program started Kestrel, sent one raw HTTP/1.1 request with its headers in this order, X-Zeta, User-Agent, Sec-CH-UA-Platform, Host, Accept-Language, Cookie, X-Alpha, Accept and Connection, and called WebRequestEvidenceService.AddEvidenceFromRequest. Evidence.AsDictionary() then gave this order.

header.Accept, header.Connection, header.Host, header.User-Agent,
header.Accept-Language, header.X-Zeta, header.Sec-CH-UA-Platform,
header.X-Alpha, cookie.51D_Cookie, query.51D_Q, server.client-ip,
header.protocol, server.request-method

So .NET's Evidence is in the order it was added, because DataBase holds it in a Dictionary<string, object> (DataBase.cs line 54), and the web integration adds the headers, cookies, query string, form, session, client IP, protocol and request line in that order (WebRequestEvidenceService.cs). Within the headers it takes Kestrel's order, being the headers Kestrel knows in Kestrel's own order and then the others as they arrived. The record grouped them by prefix, so header.protocol came after X-Alpha in the header group and server.request-method last.

Another language's web framework will order the headers its own way, so two languages give the same bytes only when they find the values in the same order. That is why the opening sentence now says so. The .NET draft for #99, 51degrees-dotnet#100, finds the Evidence first and then the web request's values for keys the Evidence does not hold (ShareUsageEvidence.Find), which fits the page's rule of the order found.

Check 3, the address order behind ServerIP. Same on this host, but the languages do not all ask the resolver.

On this Windows 11 machine the host name resolves to four addresses. .NET (Dns.GetHostAddresses), Python (socket.getaddrinfo), Node.js (dns.lookup with all) and Go (net.LookupIP) all gave fe80::fa6a:d78b:3740:52df, fe80::4be9:c889:4f11:c57d, 192.168.3.74, 172.28.96.1. Java (InetAddress.getAllByName) put the two IPv4 addresses first, in the same order. The page's rule, the first IPv4 address that is not 127.0.0.1, picks 192.168.3.74 from every one of those lists, and .NET wrote <ServerIP>192.168.3.74</ServerIP>.

The languages do not all use the resolver, though, on rc of each repository.

  • .NET resolves the host name (ShareUsageBase.cs lines 276 to 293), which is what the page says.
  • Java takes the local address of a UDP socket connected to 8.8.8.8 (getHostAddress in ShareUsageBase.java), which here was also 192.168.3.74.
  • Rust takes the address the operating system would route to a public address (server_ip in share_usage_element.rs).
  • Go takes the first IPv4 interface address that is not a loopback (serverIP in usagesharing.go), and net.InterfaceAddrs here listed 192.168.3.74 first.
  • Python and Node.js write no ServerIP element.

So on a host whose default route leaves through an interface other than the first IPv4 address of its name, Java, Rust and Go would write a different ServerIP from .NET today. Whether resolvers order a name's addresses the same way on every operating system was not tested, as only this host was available.

What each language must change

.NET is the reference, so its output for the cases the page describes needs no change. Its failures in cases H, I and K are listed above. Each other language changes in its own issue, and the checks above add these points to them.

  • Java, 51degrees-java#28. Writes Version to Platform before SessionId, where .NET writes them after ClientIP, and takes ServerIP from a route to 8.8.8.8 rather than the host name.
  • Python, 51degrees-python#8, and Node.js, 51degrees-node#16. Write the fixed elements before SessionId and write no ServerIP.
  • Go, 51degrees-go#34. Takes ServerIP from the interface addresses rather than the host name.
  • Rust, 51degrees-rust#232. Writes this form already, pinned to this draft's earlier head ae77ca4 (pipeline-engines-fiftyone/src/usage_record.rs lines 22 to 25). It should check the key that starts with . and the grouping of prefixes in another case against this update, and takes ServerIP from the route rather than the host name.
  • All. Find the values in the order the page describes, and follow Usage sharing accepts no Evidence, and reads the web request instead #99 for where they come from.

What it was checked against

The Processing section described a record with escaped and truncated
attributes that the .NET packages do not write. It now describes the record
.NET writes, completely enough for any language to write the same bytes:
the document's byte order mark, declaration and lack of whitespace, every
element of a record in order with its content, how an Evidence key becomes
an element, the truncation prefix at 10,000 UTF-16 code units, the \x form
and BadSchema for control characters, the CDATA split and the escaping, an
example, and the request detail. The example link moves to the .NET code in
51Degrees/51degrees-dotnet and the Java link goes.
jwrosewell added a commit to 51Degrees/51degrees-rust that referenced this pull request Nov 5, 2026
Part of #232. Records are now written byte for byte as draft
51Degrees/specifications#65 (at ae77ca4) sets them
out, which is the form .NET's ShareUsageBase writes.

- The new usage_record module writes the document. It starts with the UTF-8
  byte order mark and the XML declaration, has nothing between elements,
  and writes an element with no content as <Name />.
- SessionId, Sequence, DateSent and ClientIP are always written. Sequence is
  a 32-bit integer, or 0 when it is absent or is not one. Without a Sequence
  element it is read from the evidence text as .NET's int.TryParse reads it.
- Each shared evidence entry is an element named by its prefix as the key
  spells it, with the field as given in Name and the value in CDATA, where
  ]]> is split across two sections. Entries are grouped by prefix in the
  order the evidence holds them. A key with no prefix is written whole,
  with its value as text.
- A value over 10,000 UTF-16 code units keeps its first 10,000 after
  "[TRUNCATED BY USAGE SHARING] ".
- A character from U+0000 to U+001F other than tab, line feed and carriage
  return becomes \x and four hexadecimal digits, and the record gains
  <BadSchema>true</BadSchema>.
- An element name is checked against XML 1.0's name rules, so a prefix such
  as cafe with an accent is written, where it was left out before.

The escaped and truncated attributes and the capitalised prefixes go, and
quick-xml is no longer needed.
# Conflicts:
#	pipeline-specification/pipeline-elements/usage-sharing-element.md
The Evidence entries row said the accepted Evidence decides which are
shared. Usage sharing now accepts no Evidence, so the row says what rc
says, being what the element finds less what the shared Evidence rules
leave out, and the session id, sequence and client IP are the values
found for their keys.

The batching paragraph is left as rc has it, so that the change to how a
message is filled can edit it alone.

The .NET element fails the whole message for a key that starts with a
dot, so the page no longer says such a key is written. Prefixes are
grouped as the key spells them, as .NET does.
@jwrosewell

Copy link
Copy Markdown
Contributor Author

Closing without merging. The usage sharing record stays as the Processing section of usage-sharing-element.md describes it: a replaced character marks its element escaped="true", a shortened value marks it truncated="true", and there is no CDATA and no BadSchema.

Every language except .NET follows that section now. .NET keeps the record it writes today for the time being, as reference-implementation-notes.md now records (#118). .NET moves to the specification's record once both forms have been seen working downstream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant