Enriching sGTM with Firestore COGS Data: The Complete Guide to Multi-Product Purchases

Enriching sGTM with Firestore COGS Data: The Complete Guide to Multi-Product Purchases

Learn how to send product margin and cost of goods sold (COGS) data from Firestore to server-side Google Tag Manager. Compare four approaches with implementation details, tradeoffs, and recommendations.

Enriching sGTM with Firestore COGS Data: The Complete Guide to Multi-Product Purchases

Enriching sGTM with Firestore COGS Data: The Complete Guide to Multi-Product Purchases

The Problem: Multi-Product COGS Enrichment

If you’ve explored enriching server-side data with Cloud Firestore, you know the basics: use a transaction ID to look up order data and pass it to GA4. You’re ready to send cost_of_goods_sold (COGS) data for proper margin analysis. Then you hit the wall: how do you handle purchases with multiple products?

Most tutorials demonstrate single-document lookups, but when a customer buys five different products, you’re suddenly facing:

  • Multiple async calls per purchase event
  • Complex mapping logic in sGTM
  • Potential latency issues
  • No native JavaScript loops in sGTM templates

This guide covers four distinct approaches to solving this problem, with honest tradeoffs for each.


The Four Approaches

ApproachComplexityLatencyMaintenanceBest For
1. Backend EnrichmentLow (sGTM) / Medium (Backend)LowestLowTeams with backend access
2. Order-Level Firestore LookupMediumLowMediumPre-computed COGS per order
3. Batched Product QueryHighMediumHighDynamic COGS, no backend changes
4. Data Warehouse JoinLow (sGTM)None (async)MediumReporting-only use cases

Philosophy: Don’t make sGTM do math or think. Enrich the data before it ever hits your tagging infrastructure.

How It Works

1. Checkout completes
2. Backend already knows: SKUs, quantities, prices
3. Backend looks up COGS once (from DB / ERP / product table)
4. Backend writes enriched order to dataLayer or Firestore
5. sGTM receives pre-enriched data
6. sGTM passes items straight through to GA4

Implementation

Your dataLayer push at checkout should already include COGS:

dataLayer.push({
  event: 'purchase',
  ecommerce: {
    transaction_id: 'T12345',
    value: 150.00,
    currency: 'USD',
    items: [
      {
        item_id: 'SKU001',
        item_name: 'Blue Widget',
        price: 50.00,
        quantity: 1,
        cost_of_goods_sold: 22.50  // Already enriched!
      },
      {
        item_id: 'SKU002',
        item_name: 'Red Gadget',
        price: 30.00,
        quantity: 2,
        cost_of_goods_sold: 10.00  // Already enriched!
      }
    ]
  }
});

If using Firestore as your order store, write the enriched data there:

// Backend code (Node.js example)
const order = {
  transaction_id: 'T12345',
  items: cart.items.map(item => ({
    item_id: item.sku,
    price: item.price,
    quantity: item.qty,
    cost_of_goods_sold: productCatalog[item.sku].cogs  // Lookup at write time
  }))
};

await firestore.collection('orders').doc(order.transaction_id).set(order);

sGTM Configuration

With pre-enriched data, sGTM configuration is trivial:

  1. Firestore Lookup Variable (if reading from Firestore)

    • Collection Path: orders
    • Document Path: {{transaction_id}}
    • Key Path: items
  2. GA4 Event Tag

    • Event Name: purchase
    • Items Parameter: {{Firestore - Order Items}}

No loops. No mapping. No calculations.

Pros

  • Simplest sGTM implementation
  • Lowest latency (no enrichment at tag time)
  • Single source of truth for COGS
  • Easiest to debug and maintain
  • No sGTM template complexity

Cons

  • Requires backend development
  • Need to coordinate with engineering team
  • Must update dataLayer contract

When to Use

  • You have backend/engineering resources
  • You’re already touching checkout code
  • COGS data changes frequently
  • Performance is critical

Approach 2: Order-Level Firestore Lookup

Philosophy: If COGS rarely changes, pre-compute the enriched order in Firestore and let sGTM do a simple document read.

How It Works

This builds on the standard Firestore lookup pattern, but with a twist: the Firestore document already contains fully enriched items.

1. Purchase hits sGTM with transaction_id
2. sGTM reads single Firestore document: orders/{transaction_id}
3. Document contains pre-enriched items array
4. sGTM passes items straight through to GA4

Firestore Document Structure

// Collection: orders
// Document: T12345
{
  "transaction_id": "T12345",
  "value": 150.00,
  "currency": "USD",
  "items": [
    {
      "item_id": "SKU001",
      "item_name": "Blue Widget",
      "price": 50.00,
      "quantity": 1,
      "cost_of_goods_sold": 22.50
    },
    {
      "item_id": "SKU002",
      "item_name": "Red Gadget",
      "price": 30.00,
      "quantity": 2,
      "cost_of_goods_sold": 10.00
    }
  ]
}

sGTM Implementation

Variable: Firestore Order Items

Type: Firestore Lookup
Collection Path: orders
Document Path: {{Event Data - transaction_id}}
Key Path: items

GA4 Event Tag

Event Name: purchase
Event Parameters:
  - items: {{Firestore Order Items}}
  - transaction_id: {{Event Data - transaction_id}}
  - value: {{Event Data - value}}

The “Rarely Changing COGS” Workflow

If COGS only changes once or twice a year, you can use a Cloud Function to enrich orders asynchronously:

// Cloud Function triggered on new order write
exports.enrichOrder = functions.firestore
  .document('raw_orders/{orderId}')
  .onCreate(async (snap, context) => {
    const order = snap.data();
    const cogsLookup = await getCOGSMap(); // Cached COGS data

    const enrichedItems = order.items.map(item => ({
      ...item,
      cost_of_goods_sold: cogsLookup[item.item_id] || 0
    }));

    await firestore.collection('orders').doc(context.params.orderId).set({
      ...order,
      items: enrichedItems
    });
  });

Pros

  • Single Firestore call per purchase
  • Minimal sGTM complexity
  • COGS can be updated by non-developers
  • Works with standard Firestore lookup patterns

Cons

  • Requires order data in Firestore before sGTM processes
  • Potential race condition: order must be written before tag fires
  • Need Cloud Function or backend process to enrich

When to Use

  • COGS data changes infrequently (quarterly/annually)
  • You already write orders to Firestore
  • Developer resources are limited
  • You want a “set and forget” solution

Approach 3: Batched Product Query in sGTM

Philosophy: If you absolutely cannot change the backend, do the minimum possible work in sGTM: one query per event, not one query per item.

How It Works

1. Purchase hits sGTM with items array
2. Custom sGTM template extracts all item_ids
3. Single batched Firestore query: WHERE item_id IN [SKU1, SKU2, SKU3]
4. Map COGS results back onto items
5. Pass enriched items to GA4

Firestore Structure

// Collection: products
// Document: SKU001
{
  "item_id": "SKU001",
  "cogs": 22.50
}

// Document: SKU002
{
  "item_id": "SKU002",
  "cogs": 10.00
}

Custom Variable Template

Create a custom variable template in sGTM:

const Firestore = require('Firestore');
const getEventData = require('getEventData');
const JSON = require('JSON');

// Get items from the incoming event
const items = getEventData('items') || [];

if (!items.length) {
  return [];
}

// Extract unique item_ids
const itemIds = [];
for (let i = 0; i < items.length; i++) {
  const id = items[i].item_id;
  if (id && itemIds.indexOf(id) === -1) {
    itemIds.push(id);
  }
}

// Build batched query (Firestore IN query limited to 10 items)
// For larger carts, you'd need multiple queries
const queries = [['item_id', 'in', itemIds]];

return Firestore.query('products', queries, {
  projectId: data.projectId,
  limit: itemIds.length
}).then((documents) => {
  // Build lookup map
  const cogsMap = {};
  for (let i = 0; i < documents.length; i++) {
    const doc = documents[i].data;
    cogsMap[doc.item_id] = doc.cogs;
  }

  // Enrich original items
  const enrichedItems = [];
  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    enrichedItems.push({
      item_id: item.item_id,
      item_name: item.item_name,
      price: item.price,
      quantity: item.quantity,
      cost_of_goods_sold: cogsMap[item.item_id] || 0
    });
  }

  return enrichedItems;
}).catch((error) => {
  // Return original items without COGS on error
  return items;
});

Important Limitations

  1. Firestore in queries are limited to 10 values - for larger carts, you need multiple queries or a different approach
  2. No native array methods - sGTM templates don’t support .map(), .filter(), etc.
  3. Async complexity - the variable returns a Promise

Pros

  • No backend changes required
  • Single query per purchase (efficient)
  • COGS stays in Firestore (easy to update)

Cons

  • Complex sGTM template development
  • Limited to 10 items per query (Firestore limitation)
  • Adds latency to tag execution
  • Harder to debug
  • Template maintenance overhead

When to Use

  • Backend changes are absolutely not possible
  • Cart sizes are typically small (fewer than 10 items)
  • You have sGTM template development expertise
  • You accept the maintenance burden

Approach 4: Data Warehouse Join

Philosophy: Don’t enrich at collection time at all. Join COGS data downstream in your analytics infrastructure.

How It Works

1. Purchase hits sGTM with items (no COGS)
2. sGTM passes to GA4 normally
3. GA4 data exports to BigQuery
4. BigQuery JOIN with COGS table
5. Presentation layer (Looker, etc.) shows enriched data

BigQuery Implementation

COGS Reference Table

CREATE TABLE `project.analytics.product_cogs` (
  item_id STRING,
  cogs FLOAT64,
  effective_date DATE,
  expiry_date DATE
);

INSERT INTO `project.analytics.product_cogs` VALUES
('SKU001', 22.50, '2024-01-01', '2025-12-31'),
('SKU002', 10.00, '2024-01-01', '2025-12-31');

Enriched Purchase View

CREATE VIEW `project.analytics.purchases_enriched` AS
SELECT
  p.transaction_id,
  p.event_date,
  p.item_id,
  p.item_name,
  p.price,
  p.quantity,
  c.cogs AS cost_of_goods_sold,
  (p.price * p.quantity) - (c.cogs * p.quantity) AS gross_margin
FROM `project.analytics.ga4_purchases` p
LEFT JOIN `project.analytics.product_cogs` c
  ON p.item_id = c.item_id
  AND p.event_date BETWEEN c.effective_date AND c.expiry_date;

Architecture Patterns

Medallion Architecture

Bronze Layer: Raw GA4 export (no COGS)
Silver Layer: Cleaned + COGS joined
Gold Layer: Aggregated margin metrics

Looker/Looker Studio

Create a calculated field or use a pre-joined view to display COGS alongside revenue.

Pros

  • Zero sGTM complexity
  • Zero latency impact on tag execution
  • Historical COGS changes handled elegantly
  • Full SQL power for complex calculations
  • Works with any data warehouse

Cons

  • COGS not available in GA4 interface directly
  • Requires data warehouse infrastructure
  • Reporting delay (batch export)
  • More complex reporting setup

When to Use

  • Primary analysis happens in BigQuery/Looker
  • GA4 native reports aren’t the main consumption point
  • COGS changes historically and needs versioning
  • You want to avoid any sGTM complexity

Decision Framework

Choose Backend Enrichment (Approach 1) if:

  • You have engineering support
  • Performance matters
  • You want the cleanest implementation

Choose Order-Level Lookup (Approach 2) if:

  • COGS rarely changes
  • You already use Firestore for orders
  • Developer time is limited

Choose Batched Query (Approach 3) if:

  • Backend changes are impossible
  • Cart sizes are small
  • You accept template complexity

Choose Data Warehouse Join (Approach 4) if:

  • Reporting happens in BigQuery/Looker
  • Real-time COGS in GA4 isn’t required
  • You want zero sGTM overhead

Hybrid Approach: The Best of All Worlds

For maximum flexibility, consider a hybrid:

  1. Backend enriches dataLayer where possible
  2. Firestore stores COGS reference for products
  3. sGTM has fallback lookup for missing COGS
  4. Data warehouse provides historical join for auditing
// sGTM Variable: Smart COGS Resolver
const items = getEventData('items') || [];

// Check if already enriched
const allHaveCOGS = items.every(item =>
  item.cost_of_goods_sold !== undefined
);

if (allHaveCOGS) {
  // Already enriched by backend
  return items;
}

// Fallback: Query Firestore
return queryFirestoreForCOGS(items);

Performance Considerations

Latency Impact by Approach

ApproachAdded LatencyNotes
Backend Enrichment0msNo sGTM processing
Order Lookup50-150msSingle Firestore read
Batched Query100-300msFirestore query + mapping
Data Warehouse0msPost-processing only

Cost Considerations

  • Firestore reads: $0.06 per 100,000 documents
  • BigQuery: Storage + query costs vary
  • Cloud Functions: $0.40 per million invocations

For most e-commerce sites, Firestore costs for COGS lookups are negligible.


Common Pitfalls

1. Race Condition with Order Writes

Problem: sGTM tries to read the order before it’s written to Firestore.

Solution: Use Cloud Tasks or ensure synchronous write before redirect to thank-you page.

2. Firestore in Query Limit

Problem: Carts with >10 items fail the batched query.

Solution: Chunk queries into groups of 10, or use Backend Enrichment.

3. Missing COGS Data

Problem: New products don’t have COGS in Firestore yet.

Solution: Default to 0 or flag for review. Never block the purchase event.

4. sGTM Template Debugging

Problem: Custom templates are hard to debug.

Solution: Use extensive logging during development, test with Preview Mode.


Conclusion

Enriching sGTM with COGS data for multi-product purchases isn’t a one-size-fits-all problem. The “best” approach depends on your technical constraints, team resources, and where you actually consume the data.

My recommendation: Start with Backend Enrichment if you have any engineering support. It’s the cleanest, most maintainable, and most performant option. Use the Data Warehouse Join for historical analysis regardless of which real-time approach you choose.

If you’re truly constrained to sGTM-only solutions, the Order-Level Lookup with a Cloud Function enrichment process strikes the best balance between complexity and capability.

Whatever you choose, remember the core principle: Don’t make sGTM do math or think whenever possible.


FAQs

1. Can I use the Firestore Lookup variable for arrays?

Yes, the variable can return arrays. Set the Key Path to the array field (e.g., items) and it returns the full array.

2. What if my COGS changes mid-year?

Use the Data Warehouse approach with effective dates, or update your Firestore COGS collection and accept that historical purchases will show the new values.

3. How do I handle products without COGS data?

Always provide a fallback (default to 0 or null). Never let missing COGS break your purchase tracking.

4. Is there a performance difference between Firestore read vs. query?

Direct document reads (Firestore.read) are faster than queries (Firestore.query). Use reads when you know the exact document path.

5. Can I cache COGS data in sGTM?

Not natively. Each request is stateless. For frequently accessed data, consider a Redis/Memcached layer in front of Firestore, or pre-compute everything in the backend.

6. What about consent and data privacy?

COGS is typically business data, not user data. Standard Firestore security rules apply. Ensure your sGTM server has appropriate IAM permissions.

// SYS.FOOTER