<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SensorFlow</title>
    <description>The latest articles on DEV Community by SensorFlow (@sensorflow).</description>
    <link>https://dev.to/sensorflow</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4123045%2Fd2cb1876-ab98-454b-b579-f51a9afe52b4.png</url>
      <title>DEV Community: SensorFlow</title>
      <link>https://dev.to/sensorflow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sensorflow"/>
    <language>en</language>
    <item>
      <title>Go JSON Event Contracts: Unknown Fields Are Not Duplicate Fields</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Mon, 02 Nov 2026 18:36:20 +0000</pubDate>
      <link>https://dev.to/sensorflow/go-json-event-contracts-unknown-fields-are-not-duplicate-fields-3d8o</link>
      <guid>https://dev.to/sensorflow/go-json-event-contracts-unknown-fields-are-not-duplicate-fields-3d8o</guid>
      <description>&lt;p&gt;A JSON event can decode successfully while containing two competing values for the same member. Adding DisallowUnknownFields does not make those duplicates an error. It checks a different boundary: whether an incoming name can be mapped to a field in the destination struct.&lt;/p&gt;

&lt;p&gt;This matters when an event identifier, event name, or monetary property crosses multiple parsers. A validator and an ingestion service may accept the same bytes but assign different meanings to them. Before calling a contract strict, test both member uniqueness and field-name matching.&lt;/p&gt;

&lt;p&gt;Disclosure: this is an AI-generated technical article published by the SensorFlow project account. The examples are synthetic, were executed locally, and do not describe a customer incident or a completed production security assessment. There is no product integration or SDK compatibility claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the standard actually says
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8259" rel="noopener noreferrer"&gt;RFC 8259, section 4&lt;/a&gt; says object member names SHOULD be unique. It explains that implementations differ when names are repeated. Do not translate that into a claim that every JSON parser must reject duplicates, or that every parser keeps the last value.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://pkg.go.dev/encoding/json" rel="noopener noreferrer"&gt;encoding/json documentation&lt;/a&gt; describes Go's legacy behavior: duplicate members are processed in order, with later values replacing or merging with prior values depending on the destination. Struct field matching also accepts case-insensitive matches. A map with string keys does not perform that struct-field matching.&lt;/p&gt;

&lt;p&gt;These are two separate questions. The exact duplicate event/event is a duplicate JSON member. The pair event/EVENT contains distinct JSON names, but can still target the same Go struct field under legacy matching.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small regression test
&lt;/h2&gt;

&lt;p&gt;Save the following as duplicate_test.go in an empty scratch directory. Run &lt;code&gt;go test -v duplicate_test.go&lt;/code&gt;. This uses the standard encoding/json import, not encoding/json/v2, and makes no HTTP requests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json"&lt;/span&gt;
    &lt;span class="s"&gt;"strings"&lt;/span&gt;
    &lt;span class="s"&gt;"testing"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"event"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestMemberBoundaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
        &lt;span class="n"&gt;wantErr&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;}{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"ordinary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"event":"paid"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"paid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"duplicate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"event":"first","event":"second"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"second"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"case variant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"event":"first","EVENT":"second"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"second"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"unknown"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"event":"paid","extra":1}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"wrong type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"event":7}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
            &lt;span class="n"&gt;dec&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisallowUnknownFields&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wantErr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error = %v, wantErr = %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wantErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wantErr&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name = %q, want %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"map preserves case distinction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
        &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`{"event":"first","EVENT":"second"}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"first"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"EVENT"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"second"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"map = %#v, error = %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All six synthetic subtests passed locally using Go 1.26.4. The duplicate and case-variant cases are expected successes in this legacy-behavior test. A passing test therefore does not mean the input satisfies a strict event contract; it means the observed parser behavior matches the expectations encoded here.&lt;/p&gt;

&lt;p&gt;The test structure follows Go's &lt;a href="https://pkg.go.dev/testing" rel="noopener noreferrer"&gt;testing package&lt;/a&gt;, while &lt;a href="https://pkg.go.dev/strings" rel="noopener noreferrer"&gt;strings.NewReader&lt;/a&gt; supplies the input reader. The exact toolchain and import path belong in the evidence when comparing parsers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to enforce before materializing a map
&lt;/h2&gt;

&lt;p&gt;If the event contract requires unique member names, enforce that requirement at a parser boundary that can still observe every member. Once a normal map has kept a single value for an exact duplicate, checking the map cannot recover the discarded member from thin air.&lt;/p&gt;

&lt;p&gt;Also decide whether field names are case-sensitive. Rejecting exact duplicate names alone does not prevent two distinct names from mapping to one legacy struct field. Keep those two policies explicit rather than relying on a switch whose name sounds broadly strict.&lt;/p&gt;

&lt;p&gt;Do not copy this test into production as a full validator. It does not test nested duplicate objects, payload size, invalid UTF-8, multiple top-level values, authorization, or application-level field constraints. Add samples at the raw JSON boundary and compare the result at every parser your real event traverses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version changes need contract tests
&lt;/h2&gt;

&lt;p&gt;The Go team's &lt;a href="https://go.dev/blog/jsonv2-exp" rel="noopener noreferrer"&gt;2025 introduction to JSON v2&lt;/a&gt; discusses duplicate names and case-insensitive matching among the motivations for changing defaults. It describes an experiment introduced in Go 1.25; that historical article is not evidence of the current availability or defaults in every deployed Go version.&lt;/p&gt;

&lt;p&gt;Before a migration, run a fixed corpus against the actual old and new toolchains. Include duplicates with string values, case variants, nested members, and unknown properties. Record accepted/rejected results and materialized values. Treat a formerly accepted payload becoming rejected as a compatibility decision to review, rather than silently dropping it from ingestion statistics.&lt;/p&gt;

&lt;p&gt;The useful outcome is a documented contract and reproducible evidence. “JSON decoded” and “unknown fields rejected” each prove a narrow fact. Neither proves that every participant interpreted the original event identically.&lt;/p&gt;

</description>
      <category>go</category>
      <category>testing</category>
    </item>
    <item>
      <title>Which AI song generator fits a lyrics-first workflow?</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:20:44 +0000</pubDate>
      <link>https://dev.to/sensorflow/which-ai-song-generator-fits-a-lyrics-first-workflow-4bmg</link>
      <guid>https://dev.to/sensorflow/which-ai-song-generator-fits-a-lyrics-first-workflow-4bmg</guid>
      <description>&lt;p&gt;AI music tools now cover very different workflows. Some start with a short prompt, some are closer to a browser DAW, and some are built around composition or licensing. Comparing them only by “song quality” hides the decision most creators make first: do you already have lyrics, or are you starting from a blank idea?&lt;/p&gt;

&lt;p&gt;This comparison uses publicly available official product and help pages checked on November 2, 2026. Plans and features change, so treat the linked source pages as the current reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Suno&lt;/strong&gt; is a broad consumer creation suite with voices, uploads, stem splitting, covers, mashups and Studio tools. Its official pricing page separates free personal use from paid commercial rights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Udio&lt;/strong&gt; is built around credit-based song creation and editing. Its help center documents daily and monthly free limits, paid credit limits, and the fact that subscription credits do not roll over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MusicGPT&lt;/strong&gt; combines text-to-song, lyrics-to-song and audio editing features, and also exposes an API for products that need music or voice workflows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AIVA&lt;/strong&gt; is a composition-focused option with a large style catalog, MIDI/audio influence uploads, and plan-dependent licensing. Its Pro plan is the one that assigns full copyright to the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SingMyLyrics&lt;/strong&gt; is designed around a simple lyrics-first flow: paste original lyrics or start from a theme, choose a style and voice, then create a playable song page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Comparison table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Starting point&lt;/th&gt;
&lt;th&gt;Editing or production focus&lt;/th&gt;
&lt;th&gt;Licensing / plan detail&lt;/th&gt;
&lt;th&gt;Useful when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Suno&lt;/td&gt;
&lt;td&gt;Prompt, lyrics, audio, voice&lt;/td&gt;
&lt;td&gt;Voices, stems, covers, mashups, Studio&lt;/td&gt;
&lt;td&gt;Official pricing lists free personal use and paid commercial rights&lt;/td&gt;
&lt;td&gt;You want one broad creative workspace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Udio&lt;/td&gt;
&lt;td&gt;Prompt and lyrics&lt;/td&gt;
&lt;td&gt;Create, extend, remix, inpaint and edit&lt;/td&gt;
&lt;td&gt;Free accounts have daily and monthly credit limits; paid credits have separate limits&lt;/td&gt;
&lt;td&gt;You want to iterate on generated clips&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MusicGPT&lt;/td&gt;
&lt;td&gt;Text, lyrics or audio&lt;/td&gt;
&lt;td&gt;Remix, extend, replace, stems, voice and audio tools&lt;/td&gt;
&lt;td&gt;API and web plans are documented separately&lt;/td&gt;
&lt;td&gt;You need an API or post-generation editing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AIVA&lt;/td&gt;
&lt;td&gt;Prompt, composition direction, MIDI/audio influence&lt;/td&gt;
&lt;td&gt;Style models, editing and file-format export&lt;/td&gt;
&lt;td&gt;Free is non-commercial; Standard is limited commercial; Pro assigns full copyright&lt;/td&gt;
&lt;td&gt;You care about composition control and licensing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SingMyLyrics&lt;/td&gt;
&lt;td&gt;Original lyrics or a short theme&lt;/td&gt;
&lt;td&gt;Style, voice, instrumental mode and quality tier&lt;/td&gt;
&lt;td&gt;Credits are shown before generation; public songs can have their own playable page&lt;/td&gt;
&lt;td&gt;You want the shortest path from words to a shareable song&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the official pages actually say
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Suno: the widest creation surface
&lt;/h3&gt;

&lt;p&gt;Suno’s official product pages describe Voices, Stem Splitter, Covers, Mashups, Samples, Personas and Studio 2.0. The pricing page currently lists a free plan with 50 credits per day and no commercial rights, a Pro plan with 2,500 monthly credits and commercial use rights, and a Premier plan with 10,000 monthly credits and additional Studio features.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://suno.com/pricing" rel="noopener noreferrer"&gt;Suno pricing&lt;/a&gt;, &lt;a href="https://suno.com/products" rel="noopener noreferrer"&gt;Suno products&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Udio: credit limits matter to the workflow
&lt;/h3&gt;

&lt;p&gt;Udio’s help center says free accounts receive 10 daily credits plus a 100-credit monthly limit. It also explains that a pair of 32-second generations costs two credits, while a pair of approximately 130-second generations costs four credits. Standard and Pro accounts have higher monthly limits, but subscription credits do not roll over.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://help.udio.com/en/articles/10739134-credits-and-credit-limits" rel="noopener noreferrer"&gt;Udio credits and credit limits&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  MusicGPT: generation plus an API layer
&lt;/h3&gt;

&lt;p&gt;MusicGPT’s API page lists music generation, remix, extend, replace, text-to-speech, voice changer, cover, stem splitter, sound generation, lyrics generation, BPM detection and transcription. That makes it a better comparison for developers who want to embed audio features, not only for people choosing a consumer music app.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://musicgpt.com/api" rel="noopener noreferrer"&gt;MusicGPT API&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  AIVA: composition and rights are part of the product choice
&lt;/h3&gt;

&lt;p&gt;AIVA describes more than 250 styles, custom style models, MIDI or audio influence uploads, and downloads in multiple formats. Its official pricing page says the free plan is for non-commercial use, Standard supports limited monetization on selected platforms, and Pro assigns full copyright to the user.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.aiva.ai/" rel="noopener noreferrer"&gt;AIVA&lt;/a&gt;, &lt;a href="https://www.aiva.ai/legal/1" rel="noopener noreferrer"&gt;AIVA legal terms&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a lyrics-first tool fits
&lt;/h2&gt;

&lt;p&gt;If the important asset is the lyric, the workflow should keep that text visible and editable before audio generation. A useful sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Paste lyrics or describe a theme.&lt;/li&gt;
&lt;li&gt;Review the draft and correct names, dates and pronunciation.&lt;/li&gt;
&lt;li&gt;Choose style, voice, instrumental mode and quality.&lt;/li&gt;
&lt;li&gt;Generate, listen and decide whether another version is needed.&lt;/li&gt;
&lt;li&gt;Share a stable song page instead of sending an unexplained audio file.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the workflow SingMyLyrics is built to make simple. The &lt;a href="https://singmylyrics.com/lyrics-to-song" rel="noopener noreferrer"&gt;lyrics-to-song guide&lt;/a&gt; explains the input side, while the &lt;a href="https://singmylyrics.com/musicgpt-alternative" rel="noopener noreferrer"&gt;comparison page&lt;/a&gt; covers the prompt-first versus lyrics-first distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision rule
&lt;/h2&gt;

&lt;p&gt;Choose the tool based on the next action, not a generic leaderboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pick &lt;strong&gt;Suno&lt;/strong&gt; if you want a broad set of creation and editing modes in one product.&lt;/li&gt;
&lt;li&gt;Pick &lt;strong&gt;Udio&lt;/strong&gt; if credit limits and iterative clip editing fit your process.&lt;/li&gt;
&lt;li&gt;Pick &lt;strong&gt;MusicGPT&lt;/strong&gt; if you need an API or a wider audio editing toolkit.&lt;/li&gt;
&lt;li&gt;Pick &lt;strong&gt;AIVA&lt;/strong&gt; if composition control and explicit plan-level licensing are central.&lt;/li&gt;
&lt;li&gt;Pick &lt;strong&gt;SingMyLyrics&lt;/strong&gt; if you have words, a story or a memory and want to turn that input into a playable song with minimal setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No comparison table replaces a test with your own lyrics. The same verse can expose differences in pronunciation, arrangement, editability and licensing that a feature list cannot show.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources and disclosure
&lt;/h2&gt;

&lt;p&gt;This is an independent comparison, not an endorsement or affiliation with Suno, Udio, MusicGPT or AIVA. Product claims are paraphrased from the official pages linked above. The SingMyLyrics links point to the product’s public guide and comparison page.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Designing an AI song generator: the UI states beyond the prompt box</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:42:38 +0000</pubDate>
      <link>https://dev.to/sensorflow/designing-an-ai-song-generator-the-ui-states-beyond-the-prompt-box-2n47</link>
      <guid>https://dev.to/sensorflow/designing-an-ai-song-generator-the-ui-states-beyond-the-prompt-box-2n47</guid>
      <description>&lt;p&gt;Disclosure: I work on SingMyLyrics. This article was written with AI assistance. These are design recommendations, not a claim that every safeguard described below is already implemented in the product.&lt;/p&gt;

&lt;p&gt;A song generator can start with a single text box. The difficult part is explaining what happens after someone presses Generate, especially when they have pasted lyrics they care about and the request spends credits.&lt;/p&gt;

&lt;p&gt;The product context is straightforward: someone brings original words, chooses a musical style and voice, and wants to hear an arrangement. The &lt;a href="https://singmylyrics.com/lyrics-to-song" rel="noopener noreferrer"&gt;lyrics-to-song walkthrough&lt;/a&gt; shows that user-facing task. Here is how I would break down the interface contract behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Model writing and generation as separate actions
&lt;/h2&gt;

&lt;p&gt;An idea such as "a birthday song about our late-night road trips" is not the same input as a finished verse and chorus.&lt;/p&gt;

&lt;p&gt;Give the composer an explicit mode: start from an idea, or use my lyrics. If an AI writes a draft, keep the draft editable before sending it to audio generation. Avoid making a user regenerate the audio just to correct a name in the text.&lt;/p&gt;

&lt;p&gt;Treat a title as optional metadata where the provider permits it. If the provider requires one, validate that requirement before submission. The same applies to length limits: count the text that will actually be submitted, including section markers, rather than guessing from the visible number of lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Keep the user's text when a request fails
&lt;/h2&gt;

&lt;p&gt;A failed request should leave the lyrics, title and musical choices intact. Store the current draft in component state and consider a local draft with a clear delete option for recovery after navigation.&lt;/p&gt;

&lt;p&gt;Local persistence has a privacy tradeoff. Lyrics may contain personal stories. Tell users when a draft is stored on their device, avoid putting the text in URLs, and do not send full lyrics to analytics just to count form errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Separate job state from the browser's patience
&lt;/h2&gt;

&lt;p&gt;The following is an illustrative UI model, not a provider API schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;GenerationView&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submitting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;songId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checking&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;checking&lt;/code&gt; matters. A browser that has stopped waiting does not prove that the provider stopped working. Keep the job identifier and offer a status refresh. Starting a new generation should be an explicit action because it may create another charge.&lt;/p&gt;

&lt;p&gt;Use provider progress only when the provider actually reports it. Otherwise, an honest "Still generating" message is more useful than an invented percentage or a countdown that repeatedly resets.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Make credit handling a server responsibility
&lt;/h2&gt;

&lt;p&gt;Show the credit cost before submission, but resolve the authoritative price and balance on the server. A disabled Generate button improves the interface; it does not prevent duplicate requests from another tab or a network retry.&lt;/p&gt;

&lt;p&gt;A robust design needs a stable request identity, an atomic balance update and a durable record of the resulting job. Repeating the same request identity should retrieve the existing job rather than spend again. If the provider supports idempotency, pass an appropriate identity through that boundary too. Without that support, a timeout during submission needs reconciliation before resubmission.&lt;/p&gt;

&lt;p&gt;Also distinguish a credit reservation, a final charge and a refund in the ledger. The appropriate policy depends on the provider's billing behavior. Whatever policy you choose, explain it in the interface and apply refunds once, even if a failure event arrives twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make playback and download recoverable
&lt;/h2&gt;

&lt;p&gt;"Job completed" and "this browser can play the file" are different checks. The player needs a visible loading state and a useful media-error state. Keep the generated song page reachable if playback fails.&lt;/p&gt;

&lt;p&gt;If a provider returns temporary audio URLs, decide whether you will copy permitted assets into durable storage or refresh the URL through the server. Do not promise permanent downloads until the storage and retention policy supports that promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Test transitions, not just the happy path
&lt;/h2&gt;

&lt;p&gt;Before polishing the waveform, try these scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Double-click Generate and confirm that one intended request creates one job and one credit operation.&lt;/li&gt;
&lt;li&gt;Refresh while a job is pending and confirm that its status can be recovered.&lt;/li&gt;
&lt;li&gt;Let a browser request time out, then let the provider finish, and confirm that the finished song remains discoverable.&lt;/li&gt;
&lt;li&gt;Deliver the same completion or failure event twice and confirm that neither the charge nor refund is duplicated.&lt;/li&gt;
&lt;li&gt;Return a completed job with an unavailable media URL and confirm that the UI preserves the song record and offers recovery.&lt;/li&gt;
&lt;li&gt;Fail lyric generation and confirm that the original idea is still present.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These checks do not establish musical quality. They establish whether the application respects the user's input, time and credits. That is a useful foundation for any product that turns a prompt into a slower, paid generation job.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>architecture</category>
      <category>ux</category>
    </item>
    <item>
      <title>Why Yesterday's Event Report Changed Today: A Snapshot Test for Late Arrivals</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Mon, 19 Oct 2026 16:56:32 +0000</pubDate>
      <link>https://dev.to/sensorflow/why-yesterdays-event-report-changed-today-a-snapshot-test-for-late-arrivals-3e1o</link>
      <guid>https://dev.to/sensorflow/why-yesterdays-event-report-changed-today-a-snapshot-test-for-late-arrivals-3e1o</guid>
      <description>&lt;p&gt;A dashboard can show a higher count for yesterday without either query being wrong. An event may have happened yesterday, arrived after the first report ran, and been included in a later run. To investigate, keep &lt;strong&gt;event time&lt;/strong&gt;, &lt;strong&gt;arrival time&lt;/strong&gt;, and the report's &lt;strong&gt;as-of cutoff&lt;/strong&gt; separate.&lt;/p&gt;

&lt;p&gt;This AI-generated engineering note is published from the SensorFlow project account. Its synthetic code was executed locally, and the linked documentation was checked. The example is synthetic JavaScript. It is not an SDK, pipeline, or production reliability test, and it does not claim that any named analytics product loses events.&lt;/p&gt;

&lt;h2&gt;
  
  
  A report needs two boundaries
&lt;/h2&gt;

&lt;p&gt;The event-time window answers which day an action belongs to. The as-of cutoff answers which records were available to that particular report run. For this example, both are explicit UTC instants. A record occurring at &lt;code&gt;23:59Z&lt;/code&gt; but arriving at &lt;code&gt;00:10Z&lt;/code&gt; belongs to the earlier day, yet a snapshot made at &lt;code&gt;00:05Z&lt;/code&gt; cannot include it.&lt;/p&gt;

&lt;p&gt;Apache Beam's &lt;a href="https://beam.apache.org/documentation/basics/" rel="noopener noreferrer"&gt;time model&lt;/a&gt; distinguishes event time from processing time and discusses watermarks as estimates of completeness. The idea is useful even when no Beam runner is involved: a clock passing midnight does not prove that every event for the day has arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run a small snapshot test
&lt;/h2&gt;

&lt;p&gt;Save this as &lt;code&gt;snapshot.mjs&lt;/code&gt;, then run &lt;code&gt;node snapshot.mjs&lt;/code&gt;. The rows are made-up records, not captured customer data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;occurred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-14T10:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;arrived&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-14T10:01:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;occurred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-14T23:59:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;arrived&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:10:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;occurred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:02:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;arrived&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:03:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/Z$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dailyCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;asOf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;asOf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-14T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arrived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arrived&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;occurred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;occurred&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arrived&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;occurred&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;occurred&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;occurred&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;occurred&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dailyCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:05:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dailyCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:11:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dailyCount&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:11:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dailyCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:10:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dailyCount&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:11:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;dailyCount&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;occurred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-14T11:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-15T00:11:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PASS: six synthetic snapshot checks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first snapshot counts only &lt;code&gt;a&lt;/code&gt;. The later one counts &lt;code&gt;a&lt;/code&gt; and late arrival &lt;code&gt;b&lt;/code&gt;. Event &lt;code&gt;c&lt;/code&gt; belongs to the next UTC day. A repeated &lt;code&gt;a&lt;/code&gt; does not increase the logical count, while a conflicting occurrence time for the same ID is rejected by this teaching rule. The cutoff uses a half-open interval: a row arriving &lt;strong&gt;at&lt;/strong&gt; the cutoff is excluded.&lt;/p&gt;

&lt;p&gt;This example assumes the ID is stable and unique in its intended scope. A real system needs a scoped event key, conflict handling, versioned event definitions, and a defined arrival timestamp. It also needs the business's chosen calendar timezone. JavaScript &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date" rel="noopener noreferrer"&gt;Date&lt;/a&gt; stores instants; local and UTC calendar interpretations differ, while &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse" rel="noopener noreferrer"&gt;Date.parse&lt;/a&gt; can be unreliable for nonstandard strings. The explicit &lt;code&gt;Z&lt;/code&gt; strings here avoid machine-local date parsing. The test uses Node's &lt;a href="https://nodejs.org/api/assert.html" rel="noopener noreferrer"&gt;strict assertions&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explain revisions instead of hiding them
&lt;/h2&gt;

&lt;p&gt;A useful daily report records its date window, timezone, as-of cutoff, and whether it is provisional. When a count changes, compare the two snapshots by event ID: which rows arrived later, which were duplicates, and which changed definition or were rejected? That makes “late data” a testable explanation rather than a guess.&lt;/p&gt;

&lt;p&gt;The six checks above verify only the synthetic rule. They do not establish a universal wait period, an exactly-once delivery guarantee, or end-to-end event completeness. A real acceptance test must compare application outcomes, capture attempts, receiver responses, stored rows, and report filters under one declared contract.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>analytics</category>
      <category>testing</category>
    </item>
    <item>
      <title>Event Timestamps Need Units, Not Guesswork</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Mon, 19 Oct 2026 01:04:33 +0000</pubDate>
      <link>https://dev.to/sensorflow/event-timestamps-need-units-not-guesswork-24ea</link>
      <guid>https://dev.to/sensorflow/event-timestamps-need-units-not-guesswork-24ea</guid>
      <description>&lt;p&gt;An event can have a valid name, a stable identifier, and a timestamp that lands decades outside the reporting period. The request may still be valid JSON. A successful HTTP response would not tell you whether the timestamp meant seconds or milliseconds.&lt;/p&gt;

&lt;p&gt;Event timestamp validation should start with a declared unit, not a guess based on the number of digits. This tutorial builds a small, offline Node.js check for that boundary. All inputs are synthetic; no live SDK or analytics backend is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Make the unit part of the contract
&lt;/h2&gt;

&lt;p&gt;Require either a field with a defined unit, such as &lt;code&gt;occurred_at_ms&lt;/code&gt;, or a versioned source contract that explicitly declares the unit. Convert once at that boundary, then validate the normalized value. Do not silently infer seconds or milliseconds from the magnitude of an unfamiliar input.&lt;/p&gt;

&lt;p&gt;JavaScript's &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date" rel="noopener noreferrer"&gt;Date representation uses milliseconds since the Unix epoch&lt;/a&gt;. That does not mean every system producing a numeric timestamp uses milliseconds. Your adapter needs to know the producer's contract before it constructs a &lt;code&gt;Date&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a concrete boundary, this example accepts a numeric value and the separate unit &lt;code&gt;s&lt;/code&gt; or &lt;code&gt;ms&lt;/code&gt;. A real API could instead fix the unit in its schema; clients would not need to send redundant metadata on every event.&lt;/p&gt;

&lt;p&gt;The contract deliberately rejects numeric strings and fractional values. That is an example policy, not a claim that fractional-second formats are inherently invalid. If a source uses them, define precision and rounding in its own adapter rather than allowing accidental coercion here.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Run an explicit conversion check
&lt;/h2&gt;

&lt;p&gt;Save the following as &lt;code&gt;timestamp-check.cjs&lt;/code&gt;, then run &lt;code&gt;node timestamp-check.cjs&lt;/code&gt;. It needs only Node's built-in assertion module, with no packages, account, or network access.&lt;/p&gt;

&lt;p&gt;The fixture uses a fixed UTC acceptance window: the start of 2026 inclusive through the start of 2027 exclusive. This is a repeatable teaching boundary, &lt;strong&gt;not a suggested retention or backfill policy&lt;/strong&gt;. Historical imports need a window that matches their actual contract.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;END&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2027&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unit must be s or ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;time must be a numeric safe integer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normalized time is not a safe integer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;time is outside the fixture window&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;milliseconds unchanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seconds converted once&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;START&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lower bound included&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;START&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;last millisecond included&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;END&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;END&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;before window rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upper bound excluded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seconds mislabeled ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;milliseconds mislabeled s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;null rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fraction rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NaN rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;NaN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Infinity rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown unit rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;minutes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unsafe integer rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JSON non-finite round trip rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;NaN&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`PASS: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; checks passed`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeEventTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expected final lines are &lt;code&gt;16 checks passed&lt;/code&gt; and &lt;code&gt;2026-01-01T00:00:00.000Z&lt;/code&gt;. The assertion behavior is documented in &lt;a href="https://nodejs.org/api/assert.html" rel="noopener noreferrer"&gt;Node's assertion module&lt;/a&gt;. The checks exercise our example policy; they do not certify an SDK integration or production ingestion service.&lt;/p&gt;

&lt;p&gt;Notice the two integer checks. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isSafeInteger" rel="noopener noreferrer"&gt;Number.isSafeInteger&lt;/a&gt; checks the input without turning a numeric string into a number. Checking again after multiplication protects the normalized result, not just the original value.&lt;/p&gt;

&lt;p&gt;The two mislabeled-unit cases fail because the converted result is outside this fixture's window. That is useful defense in depth, not a universal unit detector: an input can have the wrong meaning and still fit a broad window. The producer's declared contract remains essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Keep validation separate from display and transport
&lt;/h2&gt;

&lt;p&gt;An ISO string is a useful inspection aid. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString" rel="noopener noreferrer"&gt;toISOString emits UTC with a Z suffix&lt;/a&gt;, which avoids depending on the machine's local display timezone. It does not verify the device clock or prove when an action happened.&lt;/p&gt;

&lt;p&gt;Validate on both sides of serialization. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify" rel="noopener noreferrer"&gt;JSON.stringify converts NaN and Infinity to null&lt;/a&gt;; an &lt;code&gt;undefined&lt;/code&gt; property on an object is omitted. The final test demonstrates why validating only the in-memory producer value is not the same as checking what a receiver sees.&lt;/p&gt;

&lt;p&gt;Keep distinct fields for event occurrence and receipt. An offline client can report an earlier action after reconnecting. Replacing its occurrence time with receipt time changes the meaning of time-based reports; preserving it does not eliminate clock skew either.&lt;/p&gt;

&lt;p&gt;For a real ingestion system, define separate policies for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unknown or unsupported units: reject or quarantine with a reason.&lt;/li&gt;
&lt;li&gt;Events outside the allowed range: distinguish historical replay from malformed input.&lt;/li&gt;
&lt;li&gt;Future-looking device clocks: use an explicit tolerance rather than silent correction.&lt;/li&gt;
&lt;li&gt;Invalid records: retain only the diagnostic fields permitted by your privacy and retention rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not turn a validation failure into a zero timestamp or today's date. Both are plausible-looking values that can hide the original problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Test the source boundary before changing old data
&lt;/h2&gt;

&lt;p&gt;For a browser producer, inspect the value immediately before serialization and the parsed request at the receiving boundary. For a server adapter, test the upstream contract and the normalized result independently. For a historical import, use the source's documented period rather than this tutorial's 2026 window.&lt;/p&gt;

&lt;p&gt;Before a migration, add a fixture with one known timestamp and its exact expected UTC instant. Preserve the source, schema version, and declared unit in whatever minimal diagnostic context your policy allows. A repaired dataset should be reproducible from a documented transformation, not from another round of digit guessing.&lt;/p&gt;

&lt;p&gt;The acceptance criterion is narrow: declared unit, correct conversion, permitted precision, and an explicit time range. It says nothing about whether the business action was genuine, the identity was correct, or the event was delivered exactly once. Test those properties separately.&lt;/p&gt;

&lt;p&gt;Start with one timestamp from each producer and add the boundary tests to that producer's contract checks. A green count is useful only when the accepted meaning is written down beside it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Affiliation and AI disclosure: this account is maintained by the SensorFlow team. This article and its synthetic checks were AI-assisted; the fixture was executed locally. This is an independent validation exercise, not a production SDK test.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>javascript</category>
      <category>testing</category>
    </item>
    <item>
      <title>Missing, Null, and Empty Are Different Event Properties</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Thu, 15 Oct 2026 14:41:16 +0000</pubDate>
      <link>https://dev.to/sensorflow/missing-null-and-empty-are-different-event-properties-581o</link>
      <guid>https://dev.to/sensorflow/missing-null-and-empty-are-different-event-properties-581o</guid>
      <description>&lt;p&gt;Affiliation: this account is maintained by the SensorFlow team. This tutorial was drafted with AI assistance; its synthetic JavaScript fixture was executed locally. It is a standalone validation exercise, not a product capability claim or a production test.&lt;/p&gt;

&lt;p&gt;An event arrives without &lt;code&gt;campaign_id&lt;/code&gt;. A convenience fallback turns it into &lt;code&gt;direct&lt;/code&gt;, and a chart now reports a business conclusion that the event never established. A missing property, an explicit &lt;code&gt;null&lt;/code&gt;, an empty string, and a real identifier should not collapse into the same value simply because they are all inconvenient to group.&lt;/p&gt;

&lt;p&gt;Event property validation should establish two separate facts: whether the property exists, and whether its value satisfies a documented contract. Only after those checks should a reporting rule assign a business meaning. A transport success response does not answer either question.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Write down what each state means
&lt;/h2&gt;

&lt;p&gt;For a synthetic &lt;code&gt;SignupCompleted&lt;/code&gt; event, this exercise uses the following policy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Absent &lt;code&gt;campaign_id&lt;/code&gt;: campaign context was not supplied.&lt;/li&gt;
&lt;li&gt;Explicit &lt;code&gt;null&lt;/code&gt;: the sender supplied an explicit no-value marker.&lt;/li&gt;
&lt;li&gt;Empty or whitespace-only string: a supplied identifier violates this contract.&lt;/li&gt;
&lt;li&gt;A nonblank string: syntactically valid campaign identifier.&lt;/li&gt;
&lt;li&gt;Any other type: invalid campaign identifier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are deliberately narrow labels. Explicit &lt;code&gt;null&lt;/code&gt; does not prove consent was denied, and absence does not prove traffic was direct. If your source can distinguish “not applicable,” “not collected,” and “collection failed,” define separate reviewed fields or states. Do not reconstruct that evidence later from a generic null.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://json-schema.org/understanding-json-schema/reference/null" rel="noopener noreferrer"&gt;JSON Schema null reference&lt;/a&gt; distinguishes a null value from an absent property. Its &lt;a href="https://json-schema.org/understanding-json-schema/reference/object" rel="noopener noreferrer"&gt;object reference&lt;/a&gt; also separates declared properties, required properties, and additional-property rules. Listing a property under &lt;code&gt;properties&lt;/code&gt; alone does not make it required.&lt;/p&gt;

&lt;p&gt;Our example accepts an absent or null campaign value but records its state. It requires an account identifier. That is a policy choice for this exercise, not a universal analytics schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Validate before choosing defaults
&lt;/h2&gt;

&lt;p&gt;Save the complete example as &lt;code&gt;property-contract.cjs&lt;/code&gt; and run &lt;code&gt;node property-contract.cjs&lt;/code&gt; with a current Node.js installation. There are no dependencies or network calls. Every identifier below is synthetic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fieldState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;absent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;explicit_null&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;empty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event must be an object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;campaign_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`unexpected field: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SignupCompleted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unexpected event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fieldState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account_id must be a nonblank own string property&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fieldState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;campaign_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;campaign&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;empty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`invalid campaign_id: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SignupCompleted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;synthetic-account-a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;synthetic-summer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;campaign_state&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;absent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;explicit_null&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;  &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SignupCompleted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SignupClicked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campain_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;typo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([]));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fieldState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inherited&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;campaign_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;absent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;})))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;campaign_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;absent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;campaign_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;})))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;campaign_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;explicit_null&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PASS: 15 synthetic acceptance checks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three accepted samples retain three different campaign states. An empty string, wrong type, misspelled extra field, missing account, or wrong event name fails loudly instead of becoming a plausible-looking chart category.&lt;/p&gt;

&lt;p&gt;The validator does not silently trim and rewrite the identifier. Whitespace-only strings fail, while other nonblank strings remain unchanged. A production contract may impose additional length or character restrictions; add them explicitly with matching tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Test the value that actually crosses the wire
&lt;/h2&gt;

&lt;p&gt;One assertion is intentionally surprising: an own property containing JavaScript &lt;code&gt;undefined&lt;/code&gt; fails this validator before serialization, but becomes absent after a JSON round trip. That is not a contradiction. These are two different objects at two different boundaries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify" rel="noopener noreferrer"&gt;MDN documents JSON.stringify's conversion rules&lt;/a&gt;: undefined-valued object properties are omitted; array elements behave differently. A sender-side object inspection therefore cannot replace checking the serialized request and the parsed receiver-side event.&lt;/p&gt;

&lt;p&gt;Test both boundaries when your application builds a payload in JavaScript. This does not mean every SDK uses &lt;code&gt;JSON.stringify&lt;/code&gt; directly; SDK transformations, batching, and encoding must be verified separately. The fixture is not an SDK transport payload or a receiver integration test.&lt;/p&gt;

&lt;p&gt;The helper uses &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwn" rel="noopener noreferrer"&gt;Object.hasOwn&lt;/a&gt; rather than truthiness or prototype-chain membership. That keeps absent fields separate from present nulls and avoids treating inherited properties as collected data. It is not a substitute for parsing untrusted input safely or for application authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Decide where strictness belongs
&lt;/h2&gt;

&lt;p&gt;Rejecting unexpected fields helps expose the typo &lt;code&gt;campain_id&lt;/code&gt;. It can also reject a legitimate new field during a rolling client deployment. This example chooses a closed, version-specific contract to make the failure observable; it does not recommend abruptly applying that policy to every production event.&lt;/p&gt;

&lt;p&gt;For production, decide how old and new producer versions coexist before tightening the receiver. A quarantine path, a versioned schema, and error counters may be more appropriate than dropping a whole batch. Preserve enough safe diagnostic context to investigate, but do not copy arbitrary personal data into validation logs.&lt;/p&gt;

&lt;p&gt;The fixture uses &lt;a href="https://nodejs.org/api/assert.html" rel="noopener noreferrer"&gt;Node's strict assertion module&lt;/a&gt; so a broken expectation stops the run. Its tests verify the listed rules, not semantic truth. A nonblank account ID can still identify the wrong account; a well-formed campaign ID can still have incorrect attribution. Those need trusted business context and integration tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Keep unknown data visible in the report
&lt;/h2&gt;

&lt;p&gt;After validation, report missing context separately from known campaign values. If you later group absent and explicit-null states into a single “unknown” display category, preserve the original state for investigation. Do not rename “unknown” to “direct” unless separate attribution evidence supports that interpretation.&lt;/p&gt;

&lt;p&gt;For a practical review, check one valid value, one absent field, one explicit null, one empty value, one wrong type, and one misspelled property. Compare the in-memory event, serialized request, parsed event, and final reporting category. A small fixture with a stated contract is more useful than a chart whose defaults quietly invent meaning.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>analytics</category>
      <category>testing</category>
    </item>
    <item>
      <title>Design Events That Answer Product Questions: A Practical Tracking Plan</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Thu, 01 Oct 2026 06:43:59 +0000</pubDate>
      <link>https://dev.to/sensorflow/design-events-that-answer-product-questions-a-practical-tracking-plan-2995</link>
      <guid>https://dev.to/sensorflow/design-events-that-answer-product-questions-a-practical-tracking-plan-2995</guid>
      <description>&lt;p&gt;When someone asks why signup conversion dropped, a generic &lt;code&gt;button_click&lt;/code&gt; event rarely answers the question. The team needs to agree on where the journey starts, what counts as completion, how users are counted, and how failures and retries are represented.&lt;/p&gt;

&lt;p&gt;An event tracking plan records those agreements before instrumentation. It connects a business question to event names, trigger conditions, identity rules, properties and acceptance tests. A working collector cannot resolve a disagreement about what an event means.&lt;/p&gt;

&lt;p&gt;This guide uses a fictional SaaS signup flow. The examples are teaching fixtures, not customer results. I work on SensorFlow, a self-hosted event analytics project; the design method below applies independently of the analytics tool you choose. AI assisted the preparation of this article, and its references and product claims were checked before publication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a question you can calculate
&lt;/h2&gt;

&lt;p&gt;Instead of asking for "signup conversion," write a more precise question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Of the distinct visitors who opened the signup page during a calendar day in our reporting timezone, how many completed account creation within 24 hours of their first qualifying page visit?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This defines the starting population, completion condition, identity unit, reporting timezone and conversion window. You still need to define how an anonymous visitor maps to an account, and whether repeated visits restart the window. Those decisions belong in the metric definition.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://amplitude.com/docs/get-started/select-events" rel="noopener noreferrer"&gt;Amplitude's event selection guide&lt;/a&gt; works back from the questions a product team wants to answer. Capturing more interactions does not automatically produce a better measurement plan. Autocapture can help explore interface behavior, but a click or form submission is not proof that an account was created. &lt;a href="https://posthog.com/docs/product-analytics/capture-events" rel="noopener noreferrer"&gt;PostHog documents automatic capture and custom events separately&lt;/a&gt;; custom business events let you express the outcome your system can actually confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define a small event contract
&lt;/h2&gt;

&lt;p&gt;For each event, write down the following fields. The table describes a suggested contract, rather than a required schema for any vendor.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Question to resolve&lt;/th&gt;
&lt;th&gt;Signup example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Event name&lt;/td&gt;
&lt;td&gt;What action or state occurred?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;signup_submitted&lt;/code&gt;, &lt;code&gt;account_created&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;At what exact point is it emitted?&lt;/td&gt;
&lt;td&gt;Emit &lt;code&gt;account_created&lt;/code&gt; after the account transaction commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;Which component owns that state?&lt;/td&gt;
&lt;td&gt;Browser for intent; backend for confirmed creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;Which stable identifier is used?&lt;/td&gt;
&lt;td&gt;Anonymous visitor before signup; account ID after creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;When did it occur, and which timezone does the report use?&lt;/td&gt;
&lt;td&gt;Preserve occurrence time and define the reporting timezone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Required properties&lt;/td&gt;
&lt;td&gt;Which dimensions are essential?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;signup_method&lt;/code&gt;, &lt;code&gt;app_version&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optional properties&lt;/td&gt;
&lt;td&gt;How is missing context represented?&lt;/td&gt;
&lt;td&gt;Missing &lt;code&gt;campaign_id&lt;/code&gt; is unknown, not silently "direct"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry rule&lt;/td&gt;
&lt;td&gt;Can one operation produce multiple events?&lt;/td&gt;
&lt;td&gt;Reuse a stable operation ID and define deduplication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owner and version&lt;/td&gt;
&lt;td&gt;Who approves a changed meaning?&lt;/td&gt;
&lt;td&gt;Product and engineering owners, plus a contract version&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://docs.snowplow.io/docs/fundamentals/tracking-design-best-practice/" rel="noopener noreferrer"&gt;Snowplow's tracking design documentation&lt;/a&gt; combines event purpose, trigger conditions and data structures in event specifications. &lt;a href="https://amplitude.com/docs/data/create-tracking-plan" rel="noopener noreferrer"&gt;Amplitude's tracking plan documentation&lt;/a&gt; also records sources, descriptions and property rules. Both are useful references when turning a short product request into something an engineer can implement and a tester can verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep names stable and put context in properties
&lt;/h2&gt;

&lt;p&gt;Avoid separate event names such as &lt;code&gt;signup_from_ad&lt;/code&gt; and &lt;code&gt;signup_from_email&lt;/code&gt; when the action is the same. A stable &lt;code&gt;signup_submitted&lt;/code&gt; event with a &lt;code&gt;signup_source&lt;/code&gt; property is easier to extend when another channel appears. &lt;a href="https://www.twilio.com/docs/segment/protocols/tracking-plan/best-practices.md" rel="noopener noreferrer"&gt;Segment's tracking plan guidance&lt;/a&gt; similarly advises against dynamic event names and property keys.&lt;/p&gt;

&lt;p&gt;Choose a naming convention and apply it consistently. Title Case or snake_case can both work; the problem is a team alternating between them without a defined mapping. Give each property a type, allowed values, and a rule for missing data. Keep email addresses, phone numbers and full form contents out of a generic analytics payload unless a separately reviewed use case requires them.&lt;/p&gt;

&lt;p&gt;Do not merge intent and outcome just to reduce the event count. &lt;code&gt;signup_submitted&lt;/code&gt; means a user tried. &lt;code&gt;account_created&lt;/code&gt; means the business state changed. If both become a vague &lt;code&gt;signup&lt;/code&gt;, a validation error can look like a successful conversion.&lt;/p&gt;

&lt;p&gt;Some tools provide recommended events, such as those described in &lt;a href="https://developers.google.com/analytics/devguides/collection/ga4/events" rel="noopener noreferrer"&gt;Google Analytics' event guide&lt;/a&gt;. Define your own business meaning first, then map it to each destination. Similar event names do not guarantee equivalent semantics across tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrument at the agreed trigger
&lt;/h2&gt;

&lt;p&gt;For a project already using the official Sensors Data JavaScript SDK, a custom intent event could have this shape after SDK initialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Record the attempt. This is not proof of account creation.&lt;/span&gt;
&lt;span class="nx"&gt;sensors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup_submitted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;signup_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;signup_source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contract_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an instrumentation fragment, not a standalone application or a live ingestion test. Initialize the SDK, identity handling and destination for your own environment; &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/docs/sensors-sdk.zh-CN.md" rel="noopener noreferrer"&gt;SensorFlow's SDK integration guide&lt;/a&gt; provides project-specific setup references. Consult the official documentation for your exact SDK version.&lt;/p&gt;

&lt;p&gt;Let the component that can confirm the final business state own the success event. An HTTP 200 response might only mean a request was accepted. If the browser and backend both emit &lt;code&gt;account_created&lt;/code&gt;, define how they are reconciled, and where a stable operation ID is generated. A property called &lt;code&gt;operation_id&lt;/code&gt; does not create deduplication by itself: the receiving and analytical layers must use it according to the contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the contract, then test the metric
&lt;/h2&gt;

&lt;p&gt;Prepare a small set of non-sensitive fixtures with expected event sequences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An anonymous visitor opens the signup page and leaves. Expect the start event and no creation event.&lt;/li&gt;
&lt;li&gt;Form validation fails. Specify whether &lt;code&gt;signup_submitted&lt;/code&gt; means every submit attempt or only a valid request; expect no creation event in either case.&lt;/li&gt;
&lt;li&gt;An account is created. Expect one confirmed creation with the correct property types.&lt;/li&gt;
&lt;li&gt;A network failure causes a retry. Check how the same operation is counted, not just how many requests were sent.&lt;/li&gt;
&lt;li&gt;A visitor signs in or continues on another device. Verify the documented anonymous-to-account mapping instead of assuming two tools merge identities identically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check raw records against the contract first. Then recalculate the metric using the same identity, timezone, window and retry rules as the report. Receiving an event and calculating an interpretable conversion rate are separate acceptance criteria.&lt;/p&gt;

&lt;p&gt;Keep expected and actual results with the release record. Those fixtures become regression tests when the SDK, application workflow or analytics destination changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose an analytics system against the contract
&lt;/h2&gt;

&lt;p&gt;An open-source or self-hosted analytics system should be evaluated against the events and questions your team needs. Check whether it receives your existing instrumentation, exposes raw records, explains identity and time behavior, and provides the analyses your users need. Also account for the deployment, access control, monitoring and upgrade work your team will own.&lt;/p&gt;

&lt;p&gt;SensorFlow is one option for teams retaining an existing Sensors Data SDK while controlling the event data pipeline. The open-source repository supports deployment and demonstration data; real SDK ingestion requires license activation. It is a focused self-hosted path rather than a feature-equivalent replacement for a mature all-in-one analytics suite, and it has no official affiliation with Sensors Data.&lt;/p&gt;

&lt;p&gt;Start by writing one event contract and checking these five fixtures. Once the team can explain the result by hand, it has a concrete basis for selecting an analytics system and reviewing its reports.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Demo Dashboard Is Not a Funnel: Auditing Synthetic Events in ClickHouse</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Thu, 01 Oct 2026 04:49:25 +0000</pubDate>
      <link>https://dev.to/sensorflow/your-demo-dashboard-is-not-a-funnel-auditing-synthetic-events-in-clickhouse-527f</link>
      <guid>https://dev.to/sensorflow/your-demo-dashboard-is-not-a-funnel-auditing-synthetic-events-in-clickhouse-527f</guid>
      <description>&lt;p&gt;A newly installed analytics stack can show a convincing sequence of "app open → product view → add to cart → purchase" before it has ingested a single real user event. That is useful for testing whether the database and dashboard start, but it is not evidence of conversion. Four non-empty bars may represent four disjoint groups of users.&lt;/p&gt;

&lt;p&gt;This is a practical audit of &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/deploy/docker/clickhouse/demo_data.sql" rel="noopener noreferrer"&gt;SensorFlow's public demo-data generator&lt;/a&gt;. SensorFlow is an independent, open-source, self-hosted event pipeline built around an existing Sensors Data SDK integration, a Go receiver, ClickHouse, and Apache Superset. It does not claim to replace every feature of a full product-analytics suite. Its installation deliberately shows synthetic data first; live SDK ingestion requires a separate license activation. That boundary makes the demo a useful example of why a chart needs a data-contract check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a four-stage dashboard prove a conversion funnel?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No.&lt;/strong&gt; Stage counts answer "did these event names occur?" A user funnel answers "did the same identified user perform these events in the required order, within the chosen time window?" Counts, distinct-user counts, user overlap, and ordered conversion are four different measures.&lt;/p&gt;

&lt;p&gt;For a real funnel, define the identity key, the start and end events, a time window, timezone, duplicate handling, and whether anonymous and logged-in IDs are merged. Without those decisions, a percentage can look precise while measuring the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect the generator before interpreting the chart
&lt;/h2&gt;

&lt;p&gt;The current &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/deploy/docker/clickhouse/demo_data.sql" rel="noopener noreferrer"&gt;demo SQL&lt;/a&gt; inserts rows from &lt;code&gt;numbers(240)&lt;/code&gt;. It selects the event name with &lt;code&gt;number % 12&lt;/code&gt;, and the demo user with &lt;code&gt;number % 48&lt;/code&gt;. Because 48 is divisible by 12, each &lt;code&gt;demo-user-*&lt;/code&gt; stays in one event category throughout this generated batch. A demo user is not walking through four stages.&lt;/p&gt;

&lt;p&gt;The script also spaces event timestamps three hours apart and assigns sample revenue to purchase rows. Its &lt;code&gt;is_first_day&lt;/code&gt; value is part of the synthetic generator, not a production calculation after identity stitching. These fields are good for rendering example charts, not for validating business conversion or GMV.&lt;/p&gt;

&lt;p&gt;The underlying &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/deploy/docker/clickhouse/init_clickhouse.sql" rel="noopener noreferrer"&gt;ClickHouse table&lt;/a&gt; is &lt;code&gt;sensors.event&lt;/code&gt;, with &lt;code&gt;time&lt;/code&gt;, &lt;code&gt;event&lt;/code&gt;, &lt;code&gt;distinct_id&lt;/code&gt;, and &lt;code&gt;app_id&lt;/code&gt;. The demo rows are marked &lt;code&gt;app_id = 'sensorflow-demo'&lt;/code&gt;. If you have installed the bundled demo, first compare event rows with distinct users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;event_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;uniqExact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distinct_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sensors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sensorflow-demo'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a fresh import of one batch from the current generator, this returns 100 app-open rows from 20 IDs, 80 product-view rows from 16 IDs, 40 add-to-cart rows from 8 IDs, and 20 purchase rows from 4 IDs. Those numbers come from the generator, not from measured customer behavior. Existing installations or changed scripts may produce different results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test whether the viewers and buyers are even the same people
&lt;/h2&gt;

&lt;p&gt;Before reporting &lt;code&gt;4 / 16 = 25%&lt;/code&gt; as a conversion rate, ask whether a single &lt;code&gt;distinct_id&lt;/code&gt; appears in both groups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;per_user&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;distinct_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;countIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'demo_product_view'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;viewed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;countIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'demo_purchase'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;purchased&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sensors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sensorflow-demo'&lt;/span&gt;
    &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distinct_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;countIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;view_users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;countIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;purchased&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;purchase_users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;countIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewed&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;purchased&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;both_users&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;per_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recreating the published generator's 240 rows with ClickHouse Local produced &lt;code&gt;16 / 4 / 0&lt;/code&gt;: sixteen viewers, four buyers, and zero IDs in both sets. Therefore &lt;code&gt;4 / 16&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; a view-to-purchase conversion rate for this demo. Even a non-zero overlap would establish only that the events occurred for the same ID, not that they happened in the right order.&lt;/p&gt;

&lt;p&gt;The query uses ClickHouse's documented &lt;a href="https://clickhouse.com/docs/reference/functions/aggregate-functions/combinators" rel="noopener noreferrer"&gt;&lt;code&gt;countIf&lt;/code&gt; aggregate combinator&lt;/a&gt; and &lt;a href="https://clickhouse.com/docs/reference/functions/aggregate-functions/uniqExact" rel="noopener noreferrer"&gt;&lt;code&gt;uniqExact&lt;/code&gt;&lt;/a&gt;. Exact distinct counts are useful when checking a small test set; ClickHouse notes that &lt;code&gt;uniqExact&lt;/code&gt; can consume more memory than approximate alternatives as cardinality grows. Do not infer production query performance from this 240-row sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a production funnel must add
&lt;/h2&gt;

&lt;p&gt;First, choose a stable identity rule. Decide whether &lt;code&gt;distinct_id&lt;/code&gt; represents a logged-in account, an anonymous browser ID, or a merged identity. Cross-device behavior and login transitions can change the denominator.&lt;/p&gt;

&lt;p&gt;Second, enforce sequence and time. A user who purchases before the product-view event must not count as a view-to-purchase conversion under an ordered definition. ClickHouse documents &lt;a href="https://clickhouse.com/docs/reference/functions/aggregate-functions/parametric-functions#windowfunnel" rel="noopener noreferrer"&gt;&lt;code&gt;windowFunnel&lt;/code&gt;&lt;/a&gt; for ordered chains inside a sliding window. Its window units depend on the timestamp argument; SensorFlow's &lt;code&gt;time&lt;/code&gt; column is &lt;code&gt;DateTime64(3)&lt;/code&gt;, so do not paste a seconds-based example into a millisecond conversion without testing it against your ClickHouse version.&lt;/p&gt;

&lt;p&gt;Third, isolate environments and reconcile the dashboard. Filter out &lt;code&gt;sensorflow-demo&lt;/code&gt;, staging traffic, and test accounts when calculating production KPIs. A Superset bar chart should use the same event names, identity rule, date boundaries, and filters as a SQL check against raw events.&lt;/p&gt;

&lt;p&gt;Finally, test the real pipeline with a small, manually checkable set of SDK events. Confirm the client request, receiver response, ClickHouse row, and Superset metric separately. &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/docs/sensors-sdk.zh-CN.md" rel="noopener noreferrer"&gt;SensorFlow's SDK integration notes&lt;/a&gt; describe the receiver URL and compatibility boundaries. &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/README.md" rel="noopener noreferrer"&gt;The README&lt;/a&gt; makes clear that installing the demo does not start live ingestion; activating it is a separate licensed step.&lt;/p&gt;

&lt;h2&gt;
  
  
  A five-step acceptance test
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read the demo or seed-data generator; determine whether it represents a real user journey at all.&lt;/li&gt;
&lt;li&gt;Compare raw rows and distinct IDs for every event stage.&lt;/li&gt;
&lt;li&gt;Check overlap between stages before calculating any conversion percentage.&lt;/li&gt;
&lt;li&gt;Add ordered-event, time-window, identity, and deduplication rules to the real-event test.&lt;/li&gt;
&lt;li&gt;Match the dashboard filters to the SQL query and exclude synthetic traffic from production reports.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The takeaway is not that synthetic dashboards are bad. They are excellent smoke tests for installation and visualization. The mistake is treating their stage distribution as evidence that real users converted. SensorFlow's transparent ClickHouse path lets a team inspect raw events and define its own metrics, but it also leaves that metric design and infrastructure operation with the team. If your organization needs a ready-made, no-SQL product-analytics interface instead, evaluate one on that basis.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>database</category>
      <category>sql</category>
      <category>opensource</category>
    </item>
    <item>
      <title>SensorFlow vs Countly in 2026: Analytics Application or SDK-to-ClickHouse Pipeline?</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/sensorflow/sensorflow-vs-countly-in-2026-analytics-application-or-sdk-to-clickhouse-pipeline-3o9m</link>
      <guid>https://dev.to/sensorflow/sensorflow-vs-countly-in-2026-analytics-application-or-sdk-to-clickhouse-pipeline-3o9m</guid>
      <description>&lt;p&gt;Countly and SensorFlow both matter to teams that want to operate analytics infrastructure, but they solve different starting problems. Countly is an analytics application with its own SDKs, event collection, reporting interface, and engagement features. SensorFlow is a narrower ingestion path for compatible existing Sensors Data SDK events: Go receiver → team-owned ClickHouse → SQL or Apache Superset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; Start with Countly if you need an integrated web, mobile, or desktop analytics product and can instrument with Countly SDKs. Test SensorFlow if you already use Sensors Data SDK, want to keep that instrumentation, and specifically need events in your own ClickHouse. Neither choice removes the need to check edition terms, actual payload compatibility, privacy duties, and operational cost.&lt;/p&gt;

&lt;p&gt;This comparison uses the projects' public documentation checked in September 2026. It is not a benchmark, legal opinion, or price quote. SensorFlow is independent of Countly and Sensors Data.&lt;/p&gt;

&lt;h2&gt;
  
  
  First decide what you are replacing
&lt;/h2&gt;

&lt;p&gt;Countly's &lt;a href="https://github.com/Countly/countly-server/blob/master/README.md" rel="noopener noreferrer"&gt;public server README&lt;/a&gt; describes mobile, web, and desktop collection, sessions, views, events, dashboards, APIs, and plugins. Countly supplies its own SDKs across Lite, Flex, and Enterprise. A team adopting it can instrument an app with a Countly SDK and use Countly's reporting interface. It is not merely a pageview counter.&lt;/p&gt;

&lt;p&gt;SensorFlow starts from another constraint: an application already sends events through a compatible Sensors Data SDK and the team does not want to rewrite every client just to change where data lands. The &lt;a href="https://github.com/data-analyze-bi/sensorFlow" rel="noopener noreferrer"&gt;SensorFlow repository&lt;/a&gt; documents this route:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Existing Sensors Data SDK clients send requests to a Go ingestion service.&lt;/li&gt;
&lt;li&gt;The service writes event rows to the team's ClickHouse.&lt;/li&gt;
&lt;li&gt;Analysts query ClickHouse directly or build Apache Superset dashboards.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The repository offers a license-free demo with sample data and Superset dashboards. &lt;strong&gt;Real SDK ingestion requires a SensorFlow license.&lt;/strong&gt; SensorFlow is not a replacement for Countly's built-in analytics and engagement UI, nor a promise that every Sensors Data SDK version or payload mode works automatically.&lt;/p&gt;

&lt;p&gt;Countly's &lt;a href="https://api.count.ly/reference" rel="noopener noreferrer"&gt;write API&lt;/a&gt; can accept data from other sources, but that is not the same as accepting Sensors Data SDK wire requests. Pointing an existing SDK server URL at Countly's collector is not a demonstrated migration; it would require an adapter or re-instrumentation and identity testing. Countly SDK calls likewise are not Sensors Data SDK calls that SensorFlow can ingest as-is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare the edition you would actually run
&lt;/h2&gt;

&lt;p&gt;Countly Lite is self-hosted and includes core event collection and dashboards. Countly's current &lt;a href="https://countly.com/features" rel="noopener noreferrer"&gt;feature matrix&lt;/a&gt; lists dashboards, event management, and mobile/web/desktop analytics for Lite. The same matrix marks funnels, retention, cohorts, and user profiles for Flex/Enterprise, not Lite. It would be misleading either to say “Countly has no funnels” or to promise that Lite includes them. Check the exact version and plan you are evaluating; packaging can change.&lt;/p&gt;

&lt;p&gt;Countly's &lt;a href="https://github.com/Countly/countly-server/blob/master/LICENSE.md" rel="noopener noreferrer"&gt;server license&lt;/a&gt; states AGPL-3.0 with modified Section 7 and branding restrictions. The &lt;a href="https://support.countly.com/hc/en-us/articles/360037501312-Countly-Licensing-FAQ" rel="noopener noreferrer"&gt;official licensing FAQ&lt;/a&gt; explains an important nuance: organizations can use Lite internally and track their own paid apps, but offering Countly as a service to customers, giving those customers dashboard access for their apps, or rebranding it has different licensing requirements. Do not reduce this to “all commercial use is forbidden,” and read the current terms for your deployment. SensorFlow's public repository uses Apache-2.0, while its real SDK-ingestion path depends on a separately supplied license.&lt;/p&gt;

&lt;h3&gt;
  
  
  A compact decision matrix
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SDK boundary:&lt;/strong&gt; Countly uses Countly SDKs or its documented write API. SensorFlow is built around compatible existing Sensors Data SDK traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data store:&lt;/strong&gt; Countly's server architecture uses MongoDB. SensorFlow's event rows go to your ClickHouse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reporting:&lt;/strong&gt; Countly supplies a product interface. SensorFlow supplies SQL/Superset workflow; your team owns metric definitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced analysis:&lt;/strong&gt; Countly's current matrix lists built-in funnels and retention for Flex/Enterprise. With SensorFlow, you must implement those definitions in SQL or another analysis layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activation:&lt;/strong&gt; Countly Lite has public source with its stated license conditions. SensorFlow's public demo works without a license; real ingestion does not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Raw data and operational trade-offs
&lt;/h2&gt;

&lt;p&gt;Countly can be self-hosted, and &lt;a href="https://support.countly.com/hc/en-us/articles/900004373266-How-Countly-Works" rel="noopener noreferrer"&gt;its architecture documentation&lt;/a&gt; says stored raw data can be retrieved through REST APIs or MongoDB commands. It would be wrong to imply that only SensorFlow permits raw-data access. The real distinction is whether you prefer Countly's MongoDB model and application workflows or need events to land directly in an existing ClickHouse environment.&lt;/p&gt;

&lt;p&gt;ClickHouse is not automatically faster or cheaper for your workload. SensorFlow puts analytical work on the operator: event schema, identity stitching, deduplication, time zones, retention, backups, access control, and SQL definitions. Countly supplies more application functionality out of the box, but requires its own deployment, SDK, and edition decisions. Test on your own representative event volume and business questions before ranking reliability or total cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  A useful proof of concept
&lt;/h2&gt;

&lt;p&gt;Ask one question: “Of users who started registration, how many completed it within 24 hours?” Agree on event names, stable user identity, time zone, window, and denominator before comparing dashboards.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Countly path:&lt;/strong&gt; Instrument a staging app with the relevant Countly SDK. Send registration-start and registration-complete events. Check that their properties appear in Countly's UI or read API. If you need a built-in funnel, verify your chosen edition includes one; do not assume Lite does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SensorFlow path:&lt;/strong&gt; Launch the demo, inspect sample rows, then activate licensed ingestion for a meaningful test. Send events from your actual Sensors Data SDK version. Query the resulting ClickHouse rows and verify event name, identity, timestamp, and property types. An HTTP success alone does not prove correct storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery path:&lt;/strong&gt; In a test environment, interrupt the store and inspect retries, duplicates, buffering, and restore behavior. A one-event demo is not a production reliability claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analyst path:&lt;/strong&gt; Have the person who will own the metric answer the registration question. Count the work required to build and maintain the answer, not only the time required to start containers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/docs/getting-started.md" rel="noopener noreferrer"&gt;SensorFlow quick start&lt;/a&gt; documents the demo-first and activation sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision
&lt;/h2&gt;

&lt;p&gt;Choose Countly when you want a self-hostable analytics application across web, mobile, or desktop, a built-in reporting workflow, and can adopt Countly instrumentation. Evaluate the Lite versus Flex/Enterprise boundary if funnels, retention, profiles, or engagement features are decisive.&lt;/p&gt;

&lt;p&gt;Evaluate SensorFlow when retaining compatible Sensors Data SDK events and landing them directly in owned ClickHouse are the hard requirements, and a SQL/Superset workflow plus licensed ingestion are acceptable. If you need Countly's integrated UI or a production ingestion path without a separate license, SensorFlow is a poor fit.&lt;/p&gt;

&lt;p&gt;The right question is not “Which has more features?” It is “Which preserves the instrumentation we have, stores data where we need it, and gives our team a reporting workflow it can actually operate?”&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>opensource</category>
      <category>database</category>
    </item>
    <item>
      <title>SensorFlow vs Umami in 2026: Website Analytics or SDK-to-ClickHouse?</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/sensorflow/sensorflow-vs-umami-in-2026-website-analytics-or-sdk-to-clickhouse-50ki</link>
      <guid>https://dev.to/sensorflow/sensorflow-vs-umami-in-2026-website-analytics-or-sdk-to-clickhouse-50ki</guid>
      <description>&lt;p&gt;Umami and SensorFlow both let a team operate analytics infrastructure, but they solve different starting problems. Umami is a self-hostable web analytics application with its own tracker and reporting interface. SensorFlow is a narrower route for compatible existing Sensors Data SDK events to reach a team-owned ClickHouse database, with SQL and Apache Superset used for analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; Choose Umami first if you want to add website analytics, events, campaigns, and conversion reporting without building a SQL-first workflow. Evaluate SensorFlow if you already have Sensors Data SDK instrumentation and the main requirement is to land those events in your own ClickHouse. Umami is an MIT-licensed open-source project. SensorFlow publishes Apache-2.0 source and a license-free demo, but real SDK ingestion requires a SensorFlow license.&lt;/p&gt;

&lt;p&gt;This comparison uses public documentation as of September 2026. It is not a benchmark, pricing quote, or claim that one product is generally better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the event source, not the dashboard
&lt;/h2&gt;

&lt;p&gt;Umami's normal website setup is to add a website, install its tracker, and read the resulting reports. Its &lt;a href="https://docs.umami.is/docs" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; covers traffic sources, visitor behavior, conversions, and revenue. It also documents custom events, funnels, and retention; describing Umami as pageviews only would be wrong. The &lt;a href="https://docs.umami.is/docs/track-events" rel="noopener noreferrer"&gt;event-tracking guide&lt;/a&gt; shows HTML data attributes and JavaScript calls.&lt;/p&gt;

&lt;p&gt;For a site with the Umami tracker installed, a named event with properties can be sent like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;umami&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup_completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call is appropriate for Umami's instrumentation model. It is not a drop-in replacement for a Sensors Data SDK call. Umami also documents a &lt;a href="https://docs.umami.is/docs/api/sending-stats" rel="noopener noreferrer"&gt;POST /api/send endpoint&lt;/a&gt;, but that endpoint has its own payload shape, website ID, and User-Agent requirement. Pointing an existing Sensors Data SDK server URL at it would not translate the protocol. A migration needs an adapter or client-side changes, plus identity testing.&lt;/p&gt;

&lt;p&gt;SensorFlow's starting point is different: retain compatible SDK instrumentation and change the receiver. The &lt;a href="https://github.com/data-analyze-bi/sensorFlow" rel="noopener noreferrer"&gt;public repository&lt;/a&gt; documents this path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sensors Data SDK events → SensorFlow Go ingestion → ClickHouse → SQL / Superset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The README separates a license-free demo from licensed real ingestion. The demo starts supporting services, sample events, and Superset dashboards. To process actual SDK traffic, a team must obtain and activate a license and test its exact SDK versions and payloads. No blanket compatibility claim is justified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the systems differ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Instrumentation.&lt;/strong&gt; Umami uses its own website tracker, JavaScript calls, or documented API. SensorFlow is evaluated as a receiver for compatible Sensors Data SDK requests. If you have no existing Sensors Data SDK dependency, that SensorFlow advantage may be irrelevant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage.&lt;/strong&gt; Umami's current &lt;a href="https://docs.umami.is/docs/install" rel="noopener noreferrer"&gt;installation guide&lt;/a&gt; specifies PostgreSQL and offers Docker Compose, a prebuilt image, or source installation. SensorFlow writes event rows to ClickHouse. This is an architectural distinction, not proof that either database wins on speed or cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analysis interface.&lt;/strong&gt; Umami offers a built-in analytics interface. Its &lt;a href="https://docs.umami.is/docs/funnel" rel="noopener noreferrer"&gt;funnel documentation&lt;/a&gt; describes ordered URL or event steps and conversion windows, and its &lt;a href="https://docs.umami.is/docs/retention" rel="noopener noreferrer"&gt;retention documentation&lt;/a&gt; describes cohorts of returning visitors. SensorFlow uses ClickHouse SQL and Superset dashboards; it does not offer an equivalent native product-analytics interface. Check feature availability in the Umami version and deployment you actually select.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terms.&lt;/strong&gt; Umami's repository has a public &lt;a href="https://github.com/umami-software/umami/blob/master/LICENSE" rel="noopener noreferrer"&gt;MIT license&lt;/a&gt;. SensorFlow's repository is Apache-2.0, but its real SDK ingestion path requires a separate license. Treat the cost and support terms as part of the decision, not as a footnote.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Umami is the better first test
&lt;/h2&gt;

&lt;p&gt;For a website team that needs traffic sources, campaigns, custom events, funnels, and retention in one interface, Umami is a more direct workflow. Install its tracker on a staging site, send an event, and ask the actual marketer or analyst to answer a question in the UI. Adding a SQL-first architecture may create work without value if the real need is a usable analytics application.&lt;/p&gt;

&lt;p&gt;Self-hosting does not remove operations. The team still needs HTTPS, access control, PostgreSQL backups, upgrades, tracker delivery, and consent handling appropriate to its own jurisdiction and data. Privacy-focused product design is not a universal legal exemption.&lt;/p&gt;

&lt;h2&gt;
  
  
  When SensorFlow is worth a proof of concept
&lt;/h2&gt;

&lt;p&gt;Suppose a team has many Sensors Data SDK calls and cannot rewrite clients all at once. It wants the event rows in a ClickHouse database it operates and has people who can own SQL metrics. Then SensorFlow's receiver boundary is worth testing: can traffic from the actual SDK versions arrive in ClickHouse with the expected identity, timestamp, and property types?&lt;/p&gt;

&lt;p&gt;That is narrower than replacing Umami. Superset can visualize a funnel or retention query, but the team must define the identities, steps, windows, and denominators, maintain the SQL, and explain the metric to users. Owning raw rows also means owning schema changes, sensitive-property handling, retention, permissions, backups, and incidents.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/docs/getting-started.md" rel="noopener noreferrer"&gt;SensorFlow quick start&lt;/a&gt; lets a developer launch the demo first, then activate licensed ingestion. If the requirement is a fully usable no-license self-hosted collection path, Umami's public terms may fit better. If the requirement is existing Sensors Data SDK traffic in ClickHouse, test SensorFlow's licensed path before deciding whether the migration value justifies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A useful evaluation for both
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;List current SDKs, event names, properties, and anonymous-to-authenticated identity transitions. Do not assume instrumentation compatibility from a feature list.&lt;/li&gt;
&lt;li&gt;Define what a registration means, the time zone, and the funnel window before opening a dashboard.&lt;/li&gt;
&lt;li&gt;For Umami, install the tracker on a non-production page, send a custom event, and confirm the event and properties in its reporting interface. A JavaScript call alone is not proof of storage.&lt;/li&gt;
&lt;li&gt;For SensorFlow, start the demo and inspect sample ClickHouse rows. If real ingestion is needed, activate the license, send one event from the actual SDK, and query it back. Check event name, distinct ID, timestamp, and property types.&lt;/li&gt;
&lt;li&gt;Let the intended users answer the same business question. Compare the product-manager workflow in Umami with the SQL/Superset workflow the team would maintain in SensorFlow.&lt;/li&gt;
&lt;li&gt;In a test environment, exercise database outage, retries, duplicates, backup restore, and credential rotation. An HTTP success response is not a reconciliation report.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not generalize a small test into a throughput or cost ranking. Measure a representative volume and operational workload before making a production decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision
&lt;/h2&gt;

&lt;p&gt;Choose Umami when the main task is adding a self-hostable website analytics application with its own instrumentation and reports. Evaluate SensorFlow when retaining compatible Sensors Data SDK traffic and landing it in owned ClickHouse are the actual constraints, and the team accepts licensed ingestion and a SQL/Superset workflow. They can coexist as separate website and product-event pipelines, but that adds governance work rather than eliminating it.&lt;/p&gt;

&lt;p&gt;SensorFlow is an independent project. It is not affiliated with, endorsed by, or certified by Umami or Sensors Data.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>analytics</category>
      <category>clickhouse</category>
    </item>
    <item>
      <title>I Built an Open-Source, Self-Hosted Alternative to PostHog on ClickHouse</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Sun, 27 Sep 2026 21:54:50 +0000</pubDate>
      <link>https://dev.to/sensorflow/i-built-an-open-source-self-hosted-alternative-to-posthog-on-clickhouse-3bcn</link>
      <guid>https://dev.to/sensorflow/i-built-an-open-source-self-hosted-alternative-to-posthog-on-clickhouse-3bcn</guid>
      <description>&lt;p&gt;Per-event pricing can make analytics costs rise with traffic. I built an alternative: &lt;strong&gt;SensorFlow&lt;/strong&gt; — an open-source, self-hosted event analytics stack from collection to SQL and BI, with data staying on your own servers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Website: &lt;a href="https://sensorflow.site/" rel="noopener noreferrer"&gt;https://sensorflow.site/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/data-analyze-bi/sensorflow" rel="noopener noreferrer"&gt;https://github.com/data-analyze-bi/sensorflow&lt;/a&gt; (Apache-2.0)&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a href="https://superset.sensorflow.site" rel="noopener noreferrer"&gt;https://superset.sensorflow.site&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem with per-event pricing
&lt;/h2&gt;

&lt;p&gt;Managed analytics charges by event volume. The more successful your product, the bigger the bill — growth gets taxed. Self-hosting flips the model: you pay for servers, not for success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Deliberately boring:&lt;/p&gt;

&lt;p&gt;SDK → Go collector → ClickHouse → SQL / Apache Superset&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Collect&lt;/strong&gt;: Go ingestion service, wire-compatible with Sensors Data SDKs — existing SDK instrumentation may be reused after checking SDK version, encryption, identity, property types, and timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store&lt;/strong&gt;: ClickHouse, built for analytical queries (funnels, retention) at scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analyze&lt;/strong&gt;: Raw SQL when you want control, Superset dashboards when you want clicks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run ./install.sh for the local demo, then obtain a license and run ./activate.sh to enable real SDK ingestion. No Kubernetes required.&lt;/p&gt;

&lt;h2&gt;
  
  
  SensorFlow vs PostHog
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SensorFlow&lt;/th&gt;
&lt;th&gt;PostHog&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;Self-hosted only&lt;/td&gt;
&lt;td&gt;Cloud or self-hosted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query model&lt;/td&gt;
&lt;td&gt;SQL-first, ClickHouse-native&lt;/td&gt;
&lt;td&gt;Product UI-first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing&lt;/td&gt;
&lt;td&gt;Annual license, $699/yr list price&lt;/td&gt;
&lt;td&gt;Usage-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suite extras&lt;/td&gt;
&lt;td&gt;None (no replay/flags)&lt;/td&gt;
&lt;td&gt;Replay, flags, experiments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PostHog is the broader suite. SensorFlow is the narrower, SQL-first option for teams that want ClickHouse and full ownership of their stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;Annual license starts at &lt;strong&gt;$699/yr&lt;/strong&gt; list price, with the first order currently discounted to &lt;strong&gt;$349&lt;/strong&gt;. It is not billed per event. The first month is free. You bring the servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Play with the &lt;a href="https://superset.sensorflow.site" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; for five minutes, then run it locally. If it's useful, a star helps: &lt;a href="https://github.com/data-analyze-bi/sensorflow" rel="noopener noreferrer"&gt;https://github.com/data-analyze-bi/sensorflow&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Independent open-source project, not affiliated with Sensors Data.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>clickhouse</category>
      <category>analytics</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>SensorFlow vs Plausible in 2026: Two Very Different Ways to Own Analytics</title>
      <dc:creator>SensorFlow</dc:creator>
      <pubDate>Sun, 27 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/sensorflow/sensorflow-vs-plausible-in-2026-two-very-different-ways-to-own-analytics-50h4</link>
      <guid>https://dev.to/sensorflow/sensorflow-vs-plausible-in-2026-two-very-different-ways-to-own-analytics-50h4</guid>
      <description>&lt;p&gt;Both Plausible and SensorFlow can put analytics data on infrastructure you operate. Both involve ClickHouse. That is where the easy equivalence ends. Plausible is a privacy-first web analytics product with a tracking script, goals, and a purpose-built dashboard. SensorFlow is a narrower ingestion route for compatible, already-deployed Sensors Data SDK events: SDK → Go receiver → your ClickHouse → Apache Superset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; If your problem is “I need readable website traffic and conversion reports,” start with Plausible. If your problem is “I already have Sensors Data SDK instrumentation and need to test landing its events in my own ClickHouse,” evaluate SensorFlow. Do not choose SensorFlow because someone says Plausible cannot self-host or cannot access raw ClickHouse data: &lt;a href="https://github.com/plausible/analytics" rel="noopener noreferrer"&gt;Plausible's own repository&lt;/a&gt; documents both. And do not assume SensorFlow's open-source demo means production SDK ingestion is free: &lt;a href="https://github.com/data-analyze-bi/sensorFlow/blob/main/docs/getting-started.md" rel="noopener noreferrer"&gt;its getting-started guide&lt;/a&gt; separates demo deployment from licensed activation.&lt;/p&gt;

&lt;p&gt;This comparison is based on vendor documentation available in September 2026. It contains no speed, cost, or privacy-compliance benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first distinction is the event contract
&lt;/h2&gt;

&lt;p&gt;Plausible's official &lt;a href="https://plausible.io/docs/events-api" rel="noopener noreferrer"&gt;Events API&lt;/a&gt; accepts POST /api/event with fields including domain, name, and url. Its JavaScript tracker can collect pageviews and custom events, and &lt;a href="https://plausible.io/docs/custom-props/for-custom-events" rel="noopener noreferrer"&gt;custom properties&lt;/a&gt; give those events context. That is a useful model for website teams. The dashboard organizes traffic sources, pages, goals, and conversions without asking an analyst to write SQL.&lt;/p&gt;

&lt;p&gt;SensorFlow's documented starting point is different: existing Sensors Data SDK calls. It attempts to receive compatible SDK payloads through a Go service and write resulting events into ClickHouse. Its core value is preserving a &lt;em&gt;tested subset&lt;/em&gt; of existing instrumentation while changing the server-side destination. That does not make its receiver a drop-in Plausible Events API endpoint. Nor does Plausible's Events API mean it automatically understands a Sensors Data SDK payload. A migration between the two needs an explicit payload mapping, identity decision, and verification of timestamps and properties.&lt;/p&gt;

&lt;p&gt;Consider a practical example: a product already has mobile and web SDK calls for SignupStarted and SignupCompleted, including a stable account identifier. If the team wants the original events for joins and custom SQL, it should test the SensorFlow path with its exact SDK version. If a marketing team chiefly needs privacy-oriented website acquisition and goal reports, it may be faster to instrument Plausible's tracker and use its interface. The event contract, not the word “open source,” decides the implementation work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plausible also uses ClickHouse
&lt;/h2&gt;

&lt;p&gt;It would be misleading to frame this as “Plausible's closed database versus SensorFlow's ClickHouse.” &lt;a href="https://github.com/plausible/analytics" rel="noopener noreferrer"&gt;Plausible's repository&lt;/a&gt; states that its self-hosted Community Edition uses ClickHouse for analytics and PostgreSQL for other application data. It also explicitly says self-hosting allows direct access to raw data in ClickHouse. Its &lt;a href="https://github.com/plausible/community-edition" rel="noopener noreferrer"&gt;Community Edition deployment repository&lt;/a&gt; provides a Docker Compose route.&lt;/p&gt;

&lt;p&gt;The more useful contrast is &lt;em&gt;data model and default workflow&lt;/em&gt;. Plausible's ClickHouse supports Plausible's web analytics product; users normally work in its dashboard and API. SensorFlow's target is a team-operated event store whose rows are queried with ClickHouse SQL and visualized in Superset. If your organization wants to own metric definitions and join events to other warehouse data, that SQL-first approach may suit it. If the organization wants a finished, simple reporting interface, operating ClickHouse alone does not provide one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Plausible&lt;/th&gt;
&lt;th&gt;SensorFlow&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary task&lt;/td&gt;
&lt;td&gt;Website and conversion analytics&lt;/td&gt;
&lt;td&gt;Compatible Sensors Data SDK event ingestion into ClickHouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud option&lt;/td&gt;
&lt;td&gt;Managed Plausible Cloud&lt;/td&gt;
&lt;td&gt;Self-hosted project; check current commercial license terms for ingestion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-host option&lt;/td&gt;
&lt;td&gt;Plausible Community Edition&lt;/td&gt;
&lt;td&gt;Docker-based demo and licensed real-ingestion path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics store&lt;/td&gt;
&lt;td&gt;ClickHouse in self-hosted CE; PostgreSQL for application data&lt;/td&gt;
&lt;td&gt;Team-operated ClickHouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default interface&lt;/td&gt;
&lt;td&gt;Plausible dashboard, goals, reports, APIs&lt;/td&gt;
&lt;td&gt;ClickHouse SQL and Apache Superset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing Sensors Data SDK&lt;/td&gt;
&lt;td&gt;Requires integration or mapping to Plausible's event model&lt;/td&gt;
&lt;td&gt;The principal compatibility use case, subject to testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in funnel UI&lt;/td&gt;
&lt;td&gt;Available in eligible managed plans, not Plausible CE&lt;/td&gt;
&lt;td&gt;No equivalent built-in no-code UI; model it in SQL/Superset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw-data access&lt;/td&gt;
&lt;td&gt;Plausible CE allows direct ClickHouse access; Cloud has export/API options&lt;/td&gt;
&lt;td&gt;Direct ClickHouse queries are the normal workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://github.com/plausible/analytics" rel="noopener noreferrer"&gt;Plausible README&lt;/a&gt; distinguishes Cloud from CE. In particular, it lists marketing funnels, ecommerce revenue goals, SSO, and Sites API among premium features absent from CE. Its &lt;a href="https://plausible.io/docs/funnel-analysis" rel="noopener noreferrer"&gt;funnel documentation&lt;/a&gt; labels the feature as a Business-plan feature. This edition boundary matters, but it is not a claim that SensorFlow has a better ready-made funnel: it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy is not a one-word score
&lt;/h2&gt;

&lt;p&gt;Plausible's product is intentionally privacy-first and designed around cookie-free website measurement. Its &lt;a href="https://github.com/plausible/analytics" rel="noopener noreferrer"&gt;repository&lt;/a&gt; describes how it avoids storing personal data and persistent visitor identifiers. The Events API documentation adds an operational nuance: User-Agent and client IP information are used to derive unique visitor counts, and a misconfigured server-side X-Forwarded-For can silently drop an event even if the API returns HTTP 202. A production implementation should follow Plausible's exact guidance rather than equate “202” with a verified stored event.&lt;/p&gt;

&lt;p&gt;SensorFlow can retain event identifiers and properties sent by an existing SDK in a ClickHouse database operated by the customer. That can enable deeper event-level analysis, but it also puts more privacy, consent, access-control, retention, and deletion responsibility on the operator. It would be wrong to market that as inherently more privacy-preserving than Plausible. The two systems are deliberately optimizing different analytics models.&lt;/p&gt;

&lt;h2&gt;
  
  
  The license and operations boundary
&lt;/h2&gt;

&lt;p&gt;Plausible CE is &lt;a href="https://github.com/plausible/analytics" rel="noopener noreferrer"&gt;AGPL-licensed&lt;/a&gt; and self-hostable without a Plausible subscription, while Plausible Cloud is a paid managed service. CE operators still pay for compute, storage, backups, maintenance, and incident response. Cloud includes a managed product and features based on the chosen plan.&lt;/p&gt;

&lt;p&gt;SensorFlow's &lt;a href="https://github.com/data-analyze-bi/sensorFlow" rel="noopener noreferrer"&gt;README&lt;/a&gt; documents an install-first workflow: supporting services and sample dashboards can run before a license is purchased, but receiving real Sensors Data SDK traffic requires a license and activation. This is a material difference from saying “clone the repository and all production ingestion works for free.” Evaluate the license alongside infrastructure and engineering costs.&lt;/p&gt;

&lt;p&gt;Do not confuse &lt;em&gt;running a demo&lt;/em&gt; with a production validation. For SensorFlow, inspect the sample events, then activate an appropriate license and send a real event from the SDK version you actually use. For Plausible, verify the chosen deployment edition, track an event with its documented API or tracker, create the required goal, and confirm that it appears in the dashboard. For both, test proxies, bot filtering, retries, backups, and access to the stored data.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fair proof of concept
&lt;/h2&gt;

&lt;p&gt;Choose one concrete question such as “How many visitors reach signup, and what properties distinguish the people who complete it?” Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write down the source events and identities. Website sessions and authenticated product users are not interchangeable units.&lt;/li&gt;
&lt;li&gt;Track the same &lt;em&gt;business outcome&lt;/em&gt; in both tools. In Plausible, use its documented custom event and goal configuration. In SensorFlow, send a compatible SDK event and query the resulting ClickHouse row.&lt;/li&gt;
&lt;li&gt;Check actual data arrival. Inspect Plausible's dropped-event header where relevant, and query ClickHouse directly for SensorFlow. Do not stop at an HTTP success response.&lt;/li&gt;
&lt;li&gt;Ask the intended users to answer the question. A marketer may prefer Plausible's ready-made dashboard; a data engineer may prefer raw rows and SQL. Neither preference is universal.&lt;/li&gt;
&lt;li&gt;Record setup and maintenance work, product entitlements, and the cost of changing existing SDK code. Do not compare a managed Plausible Cloud plan against an unoperated SensorFlow demo as though they provide the same service.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Which should you choose?
&lt;/h2&gt;

&lt;p&gt;Use Plausible when you want privacy-first website analytics with a focused dashboard and tracker. Its Community Edition is a real self-hosted option with ClickHouse and raw-data access, although the CE feature set differs from paid Cloud plans.&lt;/p&gt;

&lt;p&gt;Test SensorFlow when you already have compatible Sensors Data SDK instrumentation, explicitly want that event stream in your own ClickHouse, and have the engineering capacity to define and operate the analyses. Its narrower scope and licensed real-ingestion boundary should be stated upfront. If you need a mature out-of-the-box website analytics application, Plausible is usually the better starting point.&lt;/p&gt;

&lt;p&gt;SensorFlow is an independent project. It is not affiliated with, endorsed by, or certified by Plausible or Sensors Data.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>opensource</category>
      <category>database</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
