• Analytics
  • Databases
  • Node.js

MongoDB time series collections for extension analytics: storing daily users, ratings and versions

Store daily extension users, ratings and versions in a MongoDB time series collection: schema, granularity, duplicate-safe writes, queries and MongoDB 9.0.

Author
Extenify Team
Published
Reading time
13 min read
Grey archive shelves holding rows of old leather-bound ledgers lying on their sides, with labelled spines and paper tags

Once you collect store data every day, the history becomes the product. Users last week versus today, the day the rating slipped, the version a competitor shipped on Tuesday: all of it is time series data, and MongoDB has a collection type built for it. A MongoDB time series collection stores measurements from the same source together, compresses them by column, and creates the right index for you. It also removes some things you probably rely on, such as unique indexes and upserts.

This guide designs a MongoDB time series collection for daily extension stats (users, rating, rating count and version per store listing), shows a write path that stays duplicate-free without those features, and walks through the four queries an analytics dashboard needs. It uses the Node.js driver and notes what changed in MongoDB 9.0, released on September 28, 2026.

Key takeaways

  • Use the listing identity (store and item ID) as the metaField, a UTC day as the timeField, and the numbers as measurements.
  • For one measurement per item per day, set granularity: "hours", the coarsest built-in setting, which allows buckets of up to 30 days. You can make buckets coarser later but never finer.
  • Time series collections do not support unique indexes or upsert: true, and updates can only touch the metaField. Make daily writes idempotent with a run log and a delete-then-insert for the day.
  • $setWindowFields with $shift gives day-over-day changes, $dateTrunc gives weekly rollups, and $densify finds days your collector missed.
  • In MongoDB 9.0, time series collections are stored as a single namespace instead of a view over a buckets collection, and they can finally be renamed.

When a MongoDB time series collection fits

Time series collections are built for data where each document is a measurement from a source at a point in time, and you mostly append. MongoDB's time series overview describes the format: data from the same source is grouped into buckets in time order, stored in a columnar format that reduces disk use and I/O, and indexed on the metadata and time fields automatically (since MongoDB 6.3).

Daily store snapshots match that shape exactly. Each tracked listing produces one measurement a day, the listing never changes identity, and old days are never edited. The pattern gets more valuable as you track more listings across more stores: hundreds of competitors on the Chrome Web Store, Edge Add-ons and Firefox Add-ons, each with years of daily points.

It is the wrong tool for the current state of each listing. "What is this extension's name, icon and latest rating right now" is one document per item that you overwrite, and that wants a regular collection with a unique index and upserts. A good split is a small listings collection for the current state and a time series collection for the history.

If you only track a handful of items, a regular collection with a unique { item, day } index is simpler, and that is what our Chrome Web Store stats API guide uses. Move to time series when the history grows or when you want the storage savings.

Design the measurement document

A measurement has three parts: the time, the metadata that identifies the series, and the metrics.

const measurement = {
  ts: new Date('2026-10-05T00:00:00Z'), // timeField: the UTC day
  item: { store: 'chrome', id: 'eimadpbcbfnmbkopoojfekhnkhdbieeh' }, // metaField
  users: 7000000,
  rating: 4.7,
  ratingCount: 13200,
  version: '4.9.133',
};

The rules that matter, from MongoDB's metaField guidance and best practices:

  • The metaField should rarely or never change. Store and item ID are perfect. Do not put the rating or version in it: every distinct metaField value starts new buckets, and "collections with fine-grained or changing metaField values generate many sparsely packed, short-lived buckets".
  • Build the metaField object the same way every time. Buckets group documents whose metaField values match exactly, and field order is part of an object's value. Create it in one helper so it is always { store, id }.
  • You cannot change which fields are the timeField and metaField later. Pick names you can live with.
  • Omit fields you do not have instead of writing null or empty arrays. MongoDB's compression works per field, and changes in shape between documents hurt it.
  • Round numbers to the precision you need. The docs note that fewer decimal places improve compression. A rating to one decimal is what the store shows anyway.

Keep values consistent in type too. The Chrome Web Store abbreviates large rating counts ("13.2K") and rounds large user counts, so convert them to numbers once, in the collector, and record a flag such as approx: true if you need to remember which values were rounded. Our post on why Chrome extension weekly users drop explains why the public user count moves in steps.

Create the collection and pick a granularity

// setup.mjs: run once.
import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.MONGODB_URI);
const db = client.db('store_stats');

const existing = await db.listCollections({ name: 'listing_history' }).toArray();
if (existing.length === 0) {
  await db.createCollection('listing_history', {
    timeseries: { timeField: 'ts', metaField: 'item', granularity: 'hours' },
  });
}

const runs = db.collection('snapshot_runs'); // a regular collection
await runs.createIndex({ store: 1, day: 1 }, { unique: true });
await client.close();

Granularity controls how much time one bucket may span. The granularity docs give the limits:

Granularity Maximum time span of one bucket
seconds (default) 1 hour
minutes 24 hours
hours 30 days

MongoDB's advice is to set granularity to "the value that most closely matches the time between consecutive incoming timestamps". With one point per item per day, hours is the closest, and each bucket then holds about a month of one listing's history. With the default seconds, every daily point would land in its own bucket, which defeats the purpose.

Since MongoDB 6.3 you can instead set bucketMaxSpanSeconds and bucketRoundingSeconds (equal values, up to 31,536,000 seconds) for custom boundaries. Note the one-way door: you can change a collection's bucketing to cover longer spans with collMod, but you can never make it finer.

Write once per day without duplicates

Most people meet the limits of time series collections on their second day, when the job runs twice. The limitations page is clear:

  • Unique indexes are not supported.
  • Update commands "must not set upsert: true", and can only match on and modify the metaField.
  • You cannot write to time series collections inside transactions.

"How to prevent duplicates in a MongoDB time series collection" is a long-standing Stack Overflow question for exactly this reason. What does work is deleting: since MongoDB 7.0, the release notes say deleteMany() and the other delete operations lost most of their time series restrictions. So the daily job keeps a run log in a regular collection and replaces the whole day when it reruns:

// snapshot.mjs: run once a day from a single scheduler.
import { MongoClient } from 'mongodb';
import { collectChromeListings } from './collect.mjs'; // returns [{ id, users, rating, ... }]

const client = new MongoClient(process.env.MONGODB_URI);
const db = client.db('store_stats');
const history = db.collection('listing_history');
const runs = db.collection('snapshot_runs');

const store = 'chrome';
const day = new Date().toISOString().slice(0, 10);
const ts = new Date(`${day}T00:00:00Z`);

const done = await runs.findOne({ store, day, status: 'done' });
if (done) {
  console.log(`${store} ${day}: already stored ${done.count} items`);
} else {
  const listings = await collectChromeListings();
  const docs = listings.map(({ id, ...metrics }) => ({
    ts,
    item: { store, id },
    ...Object.fromEntries(Object.entries(metrics).filter(([, v]) => v != null)),
  }));

  // Remove anything a failed earlier run wrote for this day, then write the full set.
  await history.deleteMany({ 'item.store': store, ts });
  if (docs.length) await history.insertMany(docs, { ordered: false });

  await runs.updateOne(
    { store, day },
    { $set: { status: 'done', count: docs.length, finishedAt: new Date() } },
    { upsert: true },
  );
}
await client.close();

The run log is where upsert and the unique index live, because it is a regular collection. insertMany with ordered: false follows MongoDB's insert advice: batch writes, and keep measurements with the same metaField together. Run the job from one scheduler, not from every server process, so two copies never write the same day at the same time.

As a last line of defence, dashboards can group by item and ts and take the last value, so an accidental duplicate never doubles a chart.

Query the history

All four queries below use the default compound index on item and ts.

The latest point for every listing, for an overview table:

const latest = await history.aggregate([
  { $match: { ts: { $gte: new Date(Date.now() - 3 * 86_400_000) } } },
  { $sort: { item: 1, ts: 1 } },
  {
    $group: {
      _id: '$item',
      ts: { $last: '$ts' },
      users: { $last: '$users' },
      rating: { $last: '$rating' },
      version: { $last: '$version' },
    },
  },
]).toArray();

Day-over-day changes, the raw material for alerts. $setWindowFields with $shift (both new in 5.0) reads the previous document in each series:

const changes = await history.aggregate([
  { $match: { 'item.store': 'chrome', ts: { $gte: new Date(Date.now() - 2 * 86_400_000) } } },
  {
    $setWindowFields: {
      partitionBy: '$item',
      sortBy: { ts: 1 },
      output: {
        prevUsers: { $shift: { output: '$users', by: -1 } },
        prevRating: { $shift: { output: '$rating', by: -1 } },
        prevVersion: { $shift: { output: '$version', by: -1 } },
      },
    },
  },
  { $match: { prevVersion: { $ne: null } } }, // skip each series' first point
  {
    $match: {
      $expr: {
        $or: [
          { $ne: ['$version', '$prevVersion'] },
          { $ne: ['$rating', '$prevRating'] },
          { $gte: [{ $abs: { $subtract: ['$users', '$prevUsers'] } }, 1000] },
        ],
      },
    },
  },
]).toArray();

Feed that into whatever your team already reads. Our post on store change alerts your team will actually read covers which of these changes deserve a same-day ping.

Weekly rollups for charts longer than a few months, with $dateTrunc:

const weekly = await history.aggregate([
  { $match: { 'item.store': 'chrome', ts: { $gte: new Date('2026-01-01T00:00:00Z') } } },
  {
    $group: {
      _id: {
        item: '$item',
        week: { $dateTrunc: { date: '$ts', unit: 'week', startOfWeek: 'monday' } },
      },
      users: { $avg: '$users' },
      minRating: { $min: '$rating' },
      maxRating: { $max: '$rating' },
    },
  },
  { $sort: { '_id.week': 1 } },
]).toArray();

Days the collector missed, with $densify (new in 5.1). It fills the gaps with documents that have only the time field, so anything without users is a missing day:

const start = new Date('2026-09-01T00:00:00Z');
const end = new Date('2026-10-05T00:00:00Z');
const missing = await history.aggregate([
  { $match: { 'item.store': 'chrome', 'item.id': itemId, ts: { $gte: start, $lt: end } } },
  { $project: { _id: 0, ts: 1, users: 1 } },
  { $densify: { field: 'ts', range: { step: 1, unit: 'day', bounds: [start, end] } } },
  { $match: { users: { $exists: false } } },
]).toArray();

A gap is worth knowing about before you draw a chart: a missing day looks like a flat line in some chart libraries and a crash to zero in others.

Retention and rollups

Daily store history is small, so most teams keep it forever. If you also store something high-frequency, such as hourly rank checks, set expireAfterSeconds on that collection and keep a daily summary elsewhere. The automatic removal docs note that whole buckets are removed once every document in them has expired, by a background task that runs every 60 seconds, so data can outlive its expiry by up to a bucket's span.

To write a rollup into another time series collection, use $out with a timeseries option (MongoDB 7.0.3 and later). $merge cannot write into a time series collection, though it can read from one.

Migrate an existing snapshots collection

You cannot convert a regular collection into a time series collection; you copy the data. If your snapshots are documents like { store, itemId, day: '2026-10-05', users, rating, version }, one aggregation does it:

await db.collection('listing_snapshots').aggregate([
  { $match: { available: true } },
  {
    $project: {
      _id: 0,
      ts: { $dateFromString: { dateString: '$day' } },
      item: { store: '$store', id: '$itemId' },
      users: 1,
      rating: 1,
      version: 1,
    },
  },
  {
    $out: {
      db: 'store_stats',
      coll: 'listing_history',
      timeseries: { timeField: 'ts', metaField: 'item', granularity: 'hours' },
    },
  },
]).toArray();

Run it before the new daily job starts writing: $out replaces the target collection, and if the target exists its time series options must match exactly.

What changed in MongoDB 9.0

The MongoDB 9.0 release notes list three time series changes worth knowing:

  • Storage. Time series collections are now stored as a single namespace containing the compressed buckets, and "are no longer writable non-materialized views" over a separate buckets collection. Commands such as find and aggregate now report the namespace you queried, not an internal one.
  • Rename. renameCollection now works on time series collections, which makes a build-and-swap migration possible.
  • Upgrade note. Clusters with more than 5,000 time series collections in one database may take longer to upgrade and see write latency spikes. One collection per store or per metric is fine; one collection per tracked extension is not.

The 9.0.0 to 9.0.2 releases also have a known sharding issue that can hang a shard during step-up, fixed in 9.0.3, so check the release notes before upgrading a sharded cluster.

Limitations to plan for

You might want In a time series collection
Unique index on item and day Not supported. Use a run log and delete-then-insert.
upsert: true Not supported.
Update a measurement Only the metaField can be matched and modified.
Change streams Not supported. Trigger alerts from your job instead.
Schema validation Not supported. Validate in the collector.
Writes in a transaction Not supported. Reads are fine.
Document size 4 MB maximum.

None of these hurt a daily snapshot job once the write path is designed for them, and the storage and query benefits grow with every listing you add.

Or let Extenify keep the history

If you would rather not run collectors, schedulers and a database for this, Extenify does it for you. The Chrome Web Store stats tracker records users, ratings, reviews, versions and category rank for any listing on the Chrome Web Store, Edge Add-ons and Firefox Add-ons every day, keeps the history, merges one extension across stores the way our guide on tracking one extension across Chrome, Edge and Firefox describes, and alerts you when something changes. Tracking one extension is free, and the pricing page lists plans with longer history and CSV export.

If your history store also feeds an AI feature, keep the provider key on the server; our guide on how to hide an API key in a Chrome extension shows a proxy that logs usage to MongoDB.

Frequently asked questions

Can I upsert into a MongoDB time series collection?

No. Update commands on time series collections must not set upsert: true, and they can only match and modify the metaField. Keep upserts for a regular collection, such as a run log or a current-state collection, and use delete-then-insert for a day you need to rewrite.

How do I prevent duplicates in a MongoDB time series collection?

Unique indexes are not supported, so prevent duplicates in the write path: record each completed run in a regular collection with a unique index, skip days already marked done, and delete a day's measurements before rewriting it after a failed run. Grouping by item and time in queries is a good safety net.

Which granularity should I use for daily data?

Use hours, the coarsest built-in value, which lets one bucket span up to 30 days. The default seconds limits buckets to one hour, so daily points would each get their own bucket.

Can I convert an existing collection to a time series collection?

No. Create a new time series collection and copy the data, for example with an aggregation ending in $out with a timeseries option.

Do I need MongoDB 9.0 for this?

No. Everything here works on MongoDB 7.0.3 and later: 7.0 made deletes flexible enough for the rerun pattern, and 7.0.3 added $out into time series collections for the migration. MongoDB 9.0 adds the single-namespace storage and the ability to rename time series collections.

Summary

A MongoDB time series collection is a good home for daily extension stats: store and item ID as the metaField, a UTC day as the timeField, numbers as measurements, and hours granularity. Design the write path around the missing pieces (no unique index, no upsert, metaField-only updates) with a run log and a delete-then-insert per day. Then $setWindowFields, $dateTrunc and $densify give you the changes, rollups and gap checks a dashboard needs.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. 7 days free from your first extension, no card required, and you never touch your extension's code.

Start free