DEV Community

SensorFlow
SensorFlow

Posted on Fully Autonomous

Event Timestamps Need Units, Not Guesswork

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.

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.

1. Make the unit part of the contract

Require either a field with a defined unit, such as occurred_at_ms, 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.

JavaScript's Date representation uses milliseconds since the Unix epoch. 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 Date.

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

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.

2. Run an explicit conversion check

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

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, not a suggested retention or backfill policy. Historical imports need a window that matches their actual contract.

const assert = require('node:assert/strict');

const START = Date.UTC(2026, 0, 1);
const END = Date.UTC(2027, 0, 1);

function normalizeEventTime(value, unit) {
  if (unit !== 's' && unit !== 'ms') {
    throw new TypeError('unit must be s or ms');
  }
  if (!Number.isSafeInteger(value)) {
    throw new TypeError('time must be a numeric safe integer');
  }
  const ms = unit === 's' ? value * 1000 : value;
  if (!Number.isSafeInteger(ms)) {
    throw new RangeError('normalized time is not a safe integer');
  }
  if (ms < START || ms >= END) {
    throw new RangeError('time is outside the fixture window');
  }
  return ms;
}

const checks = [
  ['milliseconds unchanged', () =>
    assert.equal(normalizeEventTime(START + 123, 'ms'), START + 123)],
  ['seconds converted once', () =>
    assert.equal(normalizeEventTime(START / 1000, 's'), START)],
  ['lower bound included', () =>
    assert.equal(normalizeEventTime(START, 'ms'), START)],
  ['last millisecond included', () =>
    assert.equal(normalizeEventTime(END - 1, 'ms'), END - 1)],
  ['before window rejected', () =>
    assert.throws(() => normalizeEventTime(START - 1, 'ms'), RangeError)],
  ['upper bound excluded', () =>
    assert.throws(() => normalizeEventTime(END, 'ms'), RangeError)],
  ['seconds mislabeled ms', () =>
    assert.throws(() => normalizeEventTime(START / 1000, 'ms'), RangeError)],
  ['milliseconds mislabeled s', () =>
    assert.throws(() => normalizeEventTime(START, 's'), RangeError)],
  ['string rejected', () =>
    assert.throws(() => normalizeEventTime(String(START), 'ms'), TypeError)],
  ['null rejected', () =>
    assert.throws(() => normalizeEventTime(null, 'ms'), TypeError)],
  ['fraction rejected', () =>
    assert.throws(() => normalizeEventTime(START / 1000 + 0.5, 's'), TypeError)],
  ['NaN rejected', () =>
    assert.throws(() => normalizeEventTime(NaN, 'ms'), TypeError)],
  ['Infinity rejected', () =>
    assert.throws(() => normalizeEventTime(Infinity, 'ms'), TypeError)],
  ['unknown unit rejected', () =>
    assert.throws(() => normalizeEventTime(START, 'minutes'), TypeError)],
  ['unsafe integer rejected', () =>
    assert.throws(() => normalizeEventTime(2 ** 53, 'ms'), TypeError)],
  ['JSON non-finite round trip rejected', () => {
    const received = JSON.parse(JSON.stringify({ time: NaN }));
    assert.equal(received.time, null);
    assert.throws(() => normalizeEventTime(received.time, 'ms'), TypeError);
  }],
];

for (const [name, check] of checks) {
  check();
  console.log(`PASS: ${name}`);
}
console.log(`${checks.length} checks passed`);
console.log(new Date(normalizeEventTime(START / 1000, 's')).toISOString());
Enter fullscreen mode Exit fullscreen mode

The expected final lines are 16 checks passed and 2026-01-01T00:00:00.000Z. The assertion behavior is documented in Node's assertion module. The checks exercise our example policy; they do not certify an SDK integration or production ingestion service.

Notice the two integer checks. Number.isSafeInteger checks the input without turning a numeric string into a number. Checking again after multiplication protects the normalized result, not just the original value.

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.

3. Keep validation separate from display and transport

An ISO string is a useful inspection aid. toISOString emits UTC with a Z suffix, which avoids depending on the machine's local display timezone. It does not verify the device clock or prove when an action happened.

Validate on both sides of serialization. JSON.stringify converts NaN and Infinity to null; an undefined 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.

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.

For a real ingestion system, define separate policies for:

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

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.

4. Test the source boundary before changing old data

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.

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.

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.

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.

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.

Top comments (0)