Skip to content

Commit

Permalink
feat: improved logging w/ trace id
Browse files Browse the repository at this point in the history
  • Loading branch information
spencerlepine committed Oct 26, 2024
1 parent 2b4ee1f commit bef71f8
Show file tree
Hide file tree
Showing 15 changed files with 480 additions and 196 deletions.
39 changes: 20 additions & 19 deletions src/app/api/v1/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createDraftOrder, formatCartItemsForPrintify } from '@/lib/printify';
import { createCheckoutSession, formatCartItemsForStripe } from '@/lib/stripe';
import logger from '@/lib/logger';
import { createDraftOrder } from '@/lib/printify';
import { createCheckoutSession } from '@/lib/stripe';
import { UserError } from '@/utils/errors';
import validateCartItems from '@/utils/validateCartItems';
import { NextRequest, NextResponse } from 'next/server';

Expand Down Expand Up @@ -69,35 +71,34 @@ import { NextRequest, NextResponse } from 'next/server';
* type: string
*/
export const POST = async (request: NextRequest) => {
const correlationId = request.headers.get('x-correlation-id');

try {
logger.info('[Checkout] Processing checkout request', { correlationId });

const body = await request.json();
const { cartItems: clientCartItems } = body;

logger.info('[Checkout] Validating cart items', { correlationId });
const cartItems = validateCartItems(clientCartItems);
if (!cartItems) {
console.error('[Checkout] cart items are invalid', clientCartItems);
return NextResponse.json({ message: 'Unable to checkout cart items' }, { status: 400 });
}

// TODO_PRINTIFY (move this to final webhook)
const printifyLineItems = formatCartItemsForPrintify(cartItems);
const { printifyOrderId } = await createDraftOrder(printifyLineItems);
if (!printifyOrderId) {
console.error('[Printify] unable to submit a Printify order');
return NextResponse.json({ message: 'Unable to checkout cart items' }, { status: 400 });
}
logger.info('[Printify] Creating Printify draft order', { correlationId });
const { id: printifyOrderId } = await createDraftOrder(cartItems);

const stripeLineItems = formatCartItemsForStripe(cartItems);
const session = await createCheckoutSession(stripeLineItems, { printifyOrderId });
// TODO_PRINTIFY (calulateShipping())

if (!session || !session.url) {
console.error('[Stripe] error creating checkout session:', session);
return NextResponse.json({ message: 'Unable to create Stripe checkout session' }, { status: 400 });
}
logger.info('[Stripe] Creating checkout session', { correlationId, printifyOrderId });
const session = await createCheckoutSession(cartItems, { printifyOrderId });

logger.info('[Checkout] Checkout session created successfully', { checkoutUrl: session.url, correlationId, printifyOrderId, sessionId: session.id });
return NextResponse.json({ checkoutUrl: session.url });
} catch (error) {
console.error('[Checkout] Error processing checkout request:', error);
if (error instanceof UserError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}

logger.error('[Checkout] Error processing checkout request', { error });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
33 changes: 22 additions & 11 deletions src/app/api/v1/webhook/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import type { Stripe as StripeType } from 'stripe';
import { retrieveCheckoutSession, stripe } from '@/lib/stripe';
import { NextRequest, NextResponse } from 'next/server';
import { sendOrderToProduction } from '@/lib/printify';
import logger from '@/lib/logger';

export const POST = async (request: NextRequest) => {
const correlationId = request.headers.get('x-correlation-id');

try {
logger.info('[Stripe Webhook] Processing Stripe webhook request');

const secret = process.env.STRIPE_WEBHOOK_SECRET || '';
if (!secret) {
logger.error('[Stripe Webhook] Missing STRIPE_WEBHOOK_SECRET environment variable');
throw new Error('Missing STRIPE_WEBHOOK_SECRET environment variable');
}

Expand All @@ -24,40 +30,45 @@ export const POST = async (request: NextRequest) => {

const printifyOrderId = event.data.object?.metadata?.printifyOrderId;
if (!printifyOrderId) {
logger.warn(`[Stripe Webhook] Missing printifyOrderId in metadata for session ${data.id}. Unable to fullfil order`, { sessionId: data.id, correlationId });
throw new Error(`missing printifyOrderId on metadata, ${data.id}`);
}

// Make sure fulfillment hasn't already been preformed for this Checkout Session
logger.info('[Stripe Webhook] Verifying payment status for Checkout Session', { sessionId: data.id, correlationId });
const checkoutSession = await retrieveCheckoutSession(data.id);
if (checkoutSession.payment_status === 'unpaid') {
console.error('[Stripe] Webhook error: Cannot fullfil an unpaid order');
logger.warn('[Stripe Webhook] Cannot fulfill an unpaid order', { sessionId: data.id, correlationId });
return NextResponse.json({ message: 'Cannot fullfil an unpaid order' }, { status: 400 });
}

const { success } = await sendOrderToProduction(printifyOrderId);
if (!success) {
console.error('[Printify] unable to publish Printify order');
return NextResponse.json({ message: 'Unable to checkout cart items' }, { status: 400 });
}
logger.info('[Printify] Sending order to production', { sessionId: data.id, correlationId, printifyOrderId });
await sendOrderToProduction(printifyOrderId);

logger.info('[Stripe Webhook] Successfully fulfilled order', { sessionId: data.id, correlationId, printifyOrderId });
return NextResponse.json({ result: event, ok: true });

case 'payment_intent.payment_failed':
data = event.data.object as StripeType.PaymentIntent;
console.error(`[Stripe Webhook Event] ❌ Payment failed: ${data.last_payment_error?.message}`);
logger.error('[Stripe Webhook] Payment failed', { message: data.last_payment_error?.message, sessionId: data.id });
break;

case 'payment_intent.succeeded':
data = event.data.object as StripeType.PaymentIntent;
console.info(`[Stripe Webhook Event] 💰 PaymentIntent status: ${data.status}`);
logger.info('[Stripe Webhook] PaymentIntent succeeded', { status: data.status, sessionId: data.id });
break;

default:
console.warn(`[Stripe Webhook Event] Unhandled event: ${event.type}`);
data = (event.data.object as unknown) || {};
// @ts-expect-error - ignore "Property 'id' does not exist on type '{}'.ts(2339)"
logger.warn('[Stripe Webhook] Unhandled event type', { eventType: event.type, sessionId: data?.id });
return NextResponse.json({ result: event, ok: true });
}
}

logger.info('[Stripe Webhook] Webhook processing complete', { eventId: event.id });
return NextResponse.json({ result: event, ok: true });
} catch (error) {
console.error('[Stripe] Error processing webhook request:', error);
logger.error('[Stripe Webhook] Error processing webhook request', { error });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
2 changes: 1 addition & 1 deletion src/app/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import CartItemCard from '@/components/CartItemCard';
import { formatPriceForDisplay } from '@/lib/stripe';
import formatPriceForDisplay from '@/utils/formatPriceForDisplay';
import { useShoppingCart } from 'use-shopping-cart';
import { CartItem } from '@/types';
import CheckoutButton from '@/components/CheckoutBtn';
Expand Down
2 changes: 1 addition & 1 deletion src/components/CartItemCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatPriceForDisplay } from '@/lib/stripe';
import formatPriceForDisplay from '@/utils/formatPriceForDisplay';
import { CartItem } from '@/types';
import Image from 'next/image';

Expand Down
2 changes: 1 addition & 1 deletion src/components/ProductCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DEFAULT_STICKER_SIZES } from '@/lib/products';
import { formatPriceForDisplay } from '@/lib/stripe';
import formatPriceForDisplay from '@/utils/formatPriceForDisplay';
import { Product } from '@/types';
import Image from 'next/image';

Expand Down
2 changes: 1 addition & 1 deletion src/components/ProductSizeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Product, Size } from '@/types';
import AddToCartBtn from '@/components/AddToCartBtn';
import { formatPriceForDisplay } from '@/lib/stripe';
import formatPriceForDisplay from '@/utils/formatPriceForDisplay';
import { useState } from 'react';
import { DEFAULT_STICKER_SIZES } from '@/lib/products';

Expand Down
26 changes: 26 additions & 0 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import winston from 'winston';

const customFormat = winston.format.printf(({ timestamp, level, message, correlationId, ...meta }) => {
const metaString = Object.keys(meta).length ? JSON.stringify(meta) : '';
const requestIdString = correlationId ? ` requestId: ${correlationId}` : '';
return `[${timestamp}] ${level.toUpperCase()}: ${message} ${metaString}${requestIdString}`;
});

const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DDTHH:mm:ss.sssZ', // Zulu time
}),
customFormat
),
transports: [
new winston.transports.Console({
stderrLevels: ['error'],
}),
// Uncomment this line to log to a file
// new winston.transports.File({ filename: "app.log" }),
],
});

export default logger;
84 changes: 38 additions & 46 deletions src/lib/printify.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Printify from 'printify-sdk-js';
import { retrieveStickerPNGFileUrl, STICKER_SIZES } from '@/lib/products';
import { CartItem, PrintifyLineItem } from '@/types';
import { CartItem, PrintifyLineItem, SubmitOrderData } from '@/types';
import logger from './logger';

// docs: https://developers.printify.com/
// keys: https://printify.com/app/account/api
Expand Down Expand Up @@ -32,53 +33,44 @@ export const formatCartItemsForPrintify = (cartItems: CartItem[]): PrintifyLineI
}));
};

export async function createDraftOrder(lineItems: PrintifyLineItem[]): Promise<{ printifyOrderId: string }> {
try {
const randomId = crypto.randomUUID();
const randomLabel = Math.floor(Math.random() * 100000)
.toString()
.padStart(5, '0');
export async function createDraftOrder(cartItems: CartItem[]): Promise<{ id: string }> {
logger.info('[Printify] Formatting cart items for Printify');
const printifyLineItems: PrintifyLineItem[] = formatCartItemsForPrintify(cartItems);

const orderData = {
external_id: randomId,
label: `shipment_${randomLabel}`,
// TODO_PRINTIFY (pull/format from stripe)
line_items: lineItems,
shipping_method: 1,
is_printify_express: false,
is_economy_shipping: false,
send_shipping_notification: false,
// TODO_PRINTIFY (pull address from stripe)
address_to: {
first_name: 'John',
last_name: 'Doe',
email: 'testing@beta.com',
phone: '0574 69 21 90',
country: 'BE',
region: '',
address1: 'ExampleBaan 121',
address2: '45',
city: 'Retie',
zip: '2470',
},
};
const randomId = crypto.randomUUID();
const randomLabel = Math.floor(Math.random() * 100000)
.toString()
.padStart(5, '0');

const result = await printify.orders.submit(orderData);
const { id: printifyOrderId } = result;
return { printifyOrderId };
} catch (error) {
console.error('[Printify] Error submitting order:', error);
return { printifyOrderId: '' };
}
const orderData: SubmitOrderData = {
external_id: randomId,
label: `shipment_${randomLabel}`,
// TODO_PRINTIFY (pull/format from stripe)
line_items: printifyLineItems,
shipping_method: 1,
is_printify_express: false,
is_economy_shipping: false,
send_shipping_notification: false,
// TODO_PRINTIFY (pull address from stripe)
address_to: {
first_name: 'John',
last_name: 'Doe',
email: 'testing@beta.com',
phone: '0574 69 21 90',
country: 'BE',
region: '',
address1: 'ExampleBaan 121',
address2: '45',
city: 'Retie',
zip: '2470',
},
};

const order = await printify.orders.submit(orderData);
return order;
}

export async function sendOrderToProduction(printifyOrderId: string): Promise<{ success: boolean }> {
try {
console.log('[Printify] sending order to product, printifyOrderId', printifyOrderId);
await printify.orders.sendToProduction(printifyOrderId);
return { success: true };
} catch (error) {
console.error('[Printify] Error sending order to production:', error);
return { success: false };
}
export async function sendOrderToProduction(printifyOrderId: string) {
logger.info('[Printify] Sending order to production', { printifyOrderId });
await printify.orders.sendToProduction(printifyOrderId);
}
39 changes: 23 additions & 16 deletions src/lib/stripe.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Stripe from 'stripe';
import { PRODUCT_CONFIG } from '@/lib/products';
import { CartItem } from '@/types';
import logger from './logger';
import { UserError } from '@/utils/errors';

// docs: https://docs.stripe.com
// keys: https://dashboard.stripe.com/apikeys
Expand All @@ -13,15 +15,6 @@ export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
},
});

export const formatPriceForDisplay = (amount: number = 0): string => {
const numberFormat = new Intl.NumberFormat([PRODUCT_CONFIG.language], {
style: 'currency',
currency: PRODUCT_CONFIG.currency,
currencyDisplay: 'symbol',
});
return numberFormat.format(amount / 100);
};

export const formatCartItemsForStripe = (cartItems: CartItem[]): Stripe.Checkout.SessionCreateParams.LineItem[] => {
return cartItems.map(cartItem => {
const lineItem = {
Expand Down Expand Up @@ -50,12 +43,12 @@ export const formatCartItemsForStripe = (cartItems: CartItem[]): Stripe.Checkout
};

// docs: https://docs.stripe.com/api/checkout/sessions/create
export async function createCheckoutSession(
lineItems: Stripe.Checkout.SessionCreateParams.LineItem[],
metadata: { [key: string]: string } = {}
): Promise<Stripe.Response<Stripe.Checkout.Session>> {
return stripe.checkout.sessions.create({
line_items: lineItems,
export async function createCheckoutSession(cartItems: CartItem[], metadata: { [key: string]: string } = {}): Promise<Stripe.Response<Stripe.Checkout.Session>> {
logger.info('[Stripe] Formatting cart items for Stripe');
const stripeLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = formatCartItemsForStripe(cartItems);

const session = await stripe.checkout.sessions.create({
line_items: stripeLineItems,
metadata: metadata,
mode: 'payment',
success_url: `${process.env.NEXT_PUBLIC_URL}/order-confirmation?session_id={CHECKOUT_SESSION_ID}`,
Expand Down Expand Up @@ -112,12 +105,26 @@ export async function createCheckoutSession(
enabled: true, // Enable tax based on location
},
});

if (!session || !session.url) {
logger.error('[Stripe] Error creating checkout session', { session });
throw new UserError('Unable to process checkout request');
}

return session;
}

export async function retrieveCheckoutSession(sessionId: string) {
return await stripe.checkout.sessions.retrieve(sessionId, {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
});

if (!session) {
logger.error('[Stripe] Failed to process stripe checkout session');
throw new UserError('Unable to process stripe checkout session');
}

return session;
}

export async function validateStripeSession(sessionId?: string) {
Expand Down
20 changes: 20 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';

// docs: https://nextjs.org/docs/app/building-your-application/routing/middleware
export function middleware(request: NextRequest) {
const correlationId = crypto.randomUUID(); // Generate a UUID

const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-correlation-id', correlationId);

const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
return response;
}

export const config = {
matcher: '/api/v1/:path*',
};
Loading

0 comments on commit bef71f8

Please sign in to comment.