Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .opencode/opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"planetscale": {
"type": "remote",
"url": "https://mcp.pscale.dev/mcp/planetscale"
},
"axiom": {
"type": "remote",
"url": "https://mcp.axiom.co/mcp"
}
},

Expand Down
89 changes: 89 additions & 0 deletions .opencode/plans/alb-lambda-logging-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ALB Lambda Logging Fix

**Date:** 2026-03-04
**Status:** ✅ Fixed

## Problem

Express logs contain `req.id` (AWS ALB trace IDs) that don't exist in the `alb` Axiom dataset. Investigation showed ~40-60% of ALB logs for high-traffic orgs (e.g., `0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx`) were missing.

## Root Cause

The Lambda functions processing ALB logs (S3 → Lambda → Axiom) were **timing out and running out of memory** on large log files.

### Lambda Config
| Setting | Before | After |
|---------|--------|-------|
| Memory | 256 MB | 1024 MB |
| Timeout | 60 sec | 180 sec |

### Evidence
- CloudWatch showed ~100% error rate (72 invocations, 72 errors per hour)
- Errors: `Status: timeout` and `Runtime.OutOfMemory`
- Small files (164KB) processed successfully (~2000 logs in 3s)
- Large files (6-7MB compressed) failed consistently
- Same files retried multiple times before being abandoned

## Fix Applied

Lambda configuration updated via AWS Console:
- `alb-listener-us-east-2`: Memory 1024MB, Timeout 180s
- `alb-listener-us-west-2`: Check if same fix needed

## Verification (run after 24 hours)

### 1. Check Lambda errors are gone:
```bash
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda \
--metric-name Errors \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-24H +%Y-%m-%dT%H:%M:%SZ) \
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The CloudWatch command uses a macOS-specific date -v flag, so the verification steps fail on GNU/Linux environments.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .opencode/plans/alb-lambda-logging-fix.md, line 41:

<comment>The CloudWatch command uses a macOS-specific `date -v` flag, so the verification steps fail on GNU/Linux environments.</comment>

<file context>
@@ -0,0 +1,89 @@
+  --namespace AWS/Lambda \
+  --metric-name Errors \
+  --dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
+  --start-time $(date -u -v-24H +%Y-%m-%dT%H:%M:%SZ) \
+  --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
+  --period 3600 \
</file context>
Fix with Cubic

--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 \
--statistics Sum
```

### 2. Compare ALB vs Express trace IDs in Axiom:
```apl
// Sample express trace IDs for an org
['express']
| where ['context.org_id'] == '0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx'
| where isnotnull(['req.id']) and ['req.id'] startswith "Root="
| where ['_time'] > ago(1h)
| summarize count() by ['req.id']
| take 10

// Then verify each exists in ALB
['alb']
| where ['trace_id'] == "<trace_id_from_above>"
```

### 3. Check invocation success rate:
```bash
# Invocations
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda --metric-name Invocations \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-6H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 --statistics Sum

# Errors (should be 0 or near 0)
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda --metric-name Errors \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-6H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 --statistics Sum
```

## Related Resources
- S3 Buckets: `autumn-alb-us-east-2`, `autumn-alb-us-west-2`
- Lambdas: `alb-listener-us-east-2`, `alb-listener-us-west-2`
- ALBs: `fc-server-oyknwa-9b105z0` (us-east-2), `fc-server-ndcdwy-65bl04in` (us-west-2)

## Notes
- Backfilling missed logs is possible but tedious (manual Lambda re-invocation per S3 file)
- No DLQ was configured, so failed events were discarded after retries
- Consider adding a DLQ in future to catch failed processing attempts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const processConsumablePricesForInvoiceCreated = async ({
});

if (lineItems.length > 0) {
const invoiceItems = await createStripeInvoiceItems({
await createStripeInvoiceItems({
ctx,
invoiceItems: lineItemsToCreateInvoiceItemsParams({
stripeCustomerId: eventContext.stripeCustomer.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ export const executeStripeSubscriptionOperation = async ({
? "default_incomplete"
: "allow_incomplete";

// If customer's invoice_settings.default_payment_method is null but we
// resolved a payment method from the customer's attached PMs, pass it
// explicitly so Stripe knows which PM to charge.
const customerHasDefaultPm =
billingContext.stripeCustomer.invoice_settings?.default_payment_method;

const fallbackPaymentMethodParams =
paymentMethod && !customerHasDefaultPm
? { default_payment_method: paymentMethod.id }
: {};

switch (subscriptionAction.type) {
case "update": {
let stripeSubscription = billingContext.stripeSubscription;
Expand All @@ -45,11 +56,17 @@ export const executeStripeSubscriptionOperation = async ({
);
}

const subscriptionHasDefaultPm =
stripeSubscription?.default_payment_method;

return await stripeClient.subscriptions.update(
subscriptionAction.stripeSubscriptionId,
{
...subscriptionAction.params,
...invoiceModeParams,
...(subscriptionHasDefaultPm
? {}
: fallbackPaymentMethodParams),
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice"],
},
Expand All @@ -59,6 +76,7 @@ export const executeStripeSubscriptionOperation = async ({
return await stripeClient.subscriptions.create({
...subscriptionAction.params,
...invoiceModeParams,
...fallbackPaymentMethodParams,

billing_mode: { type: "flexible" },

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ export const customerProductToArrearLineItems = ({
direction: "charge",
billingTiming: "in_arrear",
now: billingContext.currentEpochMs,
currency: orgToCurrency({ org: ctx.org }),
currency:
billingContext.stripeCustomer.currency ??
orgToCurrency({ org: ctx.org }),
customerProduct,
customerPrice: cusPrice,
};
Expand Down
111 changes: 63 additions & 48 deletions server/tests/_temp/temp.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,65 @@
import { test } from "bun:test";
import { createPercentCoupon } from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import { initScenario } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import type { AutumnInt } from "@/external/autumn/autumnCli";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";

const testStripeCustomerWithGBP = async ({
ctx,
autumn,
}: {
ctx: AutumnContext;
autumn: AutumnInt;
}) => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
// 1. Create Stripe customer
const stripeCus = await stripeCli.customers.create({
email: "test-gbp@example.com",
name: "GBP Test Customer",
});

const paymentMethod = await stripeCli.paymentMethods.create({
type: "card",
card: {
token: "tok_visa",
},
});

await stripeCli.paymentMethods.attach(paymentMethod.id, {
customer: stripeCus.id,
});

const subscription = await stripeCli.subscriptions.create({
customer: stripeCus.id,
items: [
{
price_data: {
currency: "gbp",
unit_amount: 1000,
recurring: {
interval: "month",
interval_count: 1,
},
product: "prod_U5XwDFBQB4TQJ7",
},
quantity: 1,
},
],
default_payment_method: paymentMethod.id,
});

console.log("Subscription created", subscription);

// 2. Create Autumn customer with stripe_id
const autumnCus = await autumn.customers.create({
id: "gbp-test-customer",
name: "GBP Test Customer",
email: "test-gbp@example.com",
stripe_id: stripeCus.id,
});
console.log("Created:", { stripeCus: stripeCus.id, autumnCus });
};

/**
* Scenario:
Expand All @@ -21,54 +76,14 @@ import { createStripeCli } from "@/external/connect/createStripeCli";
test.concurrent(`${chalk.yellowBright("immediate-switch-discounts 3: upgrade carries over discount when coupon is deleted")}`, async () => {
const customerId = "temp";

const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});

const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});

const { autumnV1, testClockId, ctx } = await initScenario({
customerId,
// customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
// s.customer({ paymentMethod: "success" }),
// s.products({ list: [pro, premium] }),
],
actions: [],
});

const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });

const coupon = await createPercentCoupon({
stripeCli,
percentOff: 20,
duration: "repeating",
durationInMonths: 1,
});

await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
discounts: [
{
reward_id: coupon.id,
},
],
});

await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});

await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
await testStripeCustomerWithGBP({ ctx, autumn: autumnV1 });
});
Comment on lines 77 to 89
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Work-in-progress test committed with commented-out setup

The initScenario call has customerId, s.customer, and s.products all commented out. This means the scenario initialises with no customer and no products, so the ctx object passed to testStripeCustomerWithGBP may lack a properly configured org/environment. Since the hardcoded Stripe product ID ("prod_U5XwDFBQB4TQJ7") also looks environment-specific, this test is likely to silently no-op or fail in other environments. Consider either fully enabling it (with proper fixtures) or keeping it out of the committed codebase until it's ready.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/tests/_temp/temp.test.ts
Line: 77-89

Comment:
**Work-in-progress test committed with commented-out setup**

The `initScenario` call has `customerId`, `s.customer`, and `s.products` all commented out. This means the scenario initialises with no customer and no products, so the `ctx` object passed to `testStripeCustomerWithGBP` may lack a properly configured org/environment. Since the hardcoded Stripe `product` ID (`"prod_U5XwDFBQB4TQJ7"`) also looks environment-specific, this test is likely to silently no-op or fail in other environments. Consider either fully enabling it (with proper fixtures) or keeping it out of the committed codebase until it's ready.

How can I resolve this? If you propose a fix, please make it concise.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Attach Edge Case - Payment method exists but invoice_settings.default_payment_method is null
*
* Scenario: Customer has a payment method on file (card attached), but the
* Stripe customer's invoice_settings.default_payment_method has been cleared.
* Verifies that billing.attach still succeeds (Stripe can still charge the customer).
*/

import { test } from "bun:test";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { expectCustomerProductCorrect } from "../../utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "../../utils/expectStripeSubCorrect";

test.concurrent(`${chalk.yellowBright("pm-edge-case 1: attach succeeds when invoice_settings.default_payment_method is null")}`, async () => {
const customerId = "attach-pm-edge-null-default";

const proMessagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});

const { autumnV1, ctx, customer } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});

const stripeCustomerId = customer.processor?.id;
if (!stripeCustomerId) throw new Error("No stripe customer id");

await ctx.stripeCli.customers.update(stripeCustomerId, {
invoice_settings: {
default_payment_method: "" as string,
},
});

await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
});

const customerAfter = await autumnV1.customers.get(customerId);
await expectCustomerProductCorrect({
customerId,
customer: customerAfter,
productId: pro.id,
state: "active",
});

await expectStripeSubscriptionCorrect({ ctx, customerId });
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";

Expand Down
Loading