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
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
| Approach | Complexity | Latency | Maintenance | Best For |
|---|---|---|---|---|
| 1. Backend Enrichment | Low (sGTM) / Medium (Backend) | Lowest | Low | Teams with backend access |
| 2. Order-Level Firestore Lookup | Medium | Low | Medium | Pre-computed COGS per order |
| 3. Batched Product Query | High | Medium | High | Dynamic COGS, no backend changes |
| 4. Data Warehouse Join | Low (sGTM) | None (async) | Medium | Reporting-only use cases |
Approach 1: Backend Enrichment (Recommended)
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:
-
Firestore Lookup Variable (if reading from Firestore)
- Collection Path:
orders - Document Path:
{{transaction_id}} - Key Path:
items
- Collection Path:
-
- Event Name:
purchase - Items Parameter:
{{Firestore - Order Items}}
- Event Name:
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
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
- Firestore
inqueries are limited to 10 values - for larger carts, you need multiple queries or a different approach - No native array methods - sGTM templates don’t support
.map(),.filter(), etc. - 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:
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:
- Backend enriches dataLayer where possible
- Firestore stores COGS reference for products
- sGTM has fallback lookup for missing COGS
- 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
| Approach | Added Latency | Notes |
|---|---|---|
| Backend Enrichment | 0ms | No sGTM processing |
| Order Lookup | 50-150ms | Single Firestore read |
| Batched Query | 100-300ms | Firestore query + mapping |
| Data Warehouse | 0ms | Post-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.