Skip to content

Cloud Run and other Node servers

A POST to /log on your Express server writes the same entries a callable would, in the same Cloud Logging stream.

Two things differ from the Cloud Functions path. The server receives logs over HTTP instead of exporting a callable, and it has to decide who may send them. Breadcrumbs, labels, symbolication and the free fields work the same, and entries arrive in the same stream in the same shape.

createHttpLogHandler takes Node’s request and response shapes. Parse the body first, and raise the limit if the client sends attachments.

src/server.ts
import express from 'express'
import { getAuth } from 'firebase-admin/auth'
import { initLogger, createHttpLogHandler } from '@dasasian/firebase-structured-logger/functions'
initLogger({ appId: 'my-app' })
const app = express()
app.use(express.json({ limit: '10mb' }))
app.all('/log', createHttpLogHandler({
bucket: 'my-app.firebasestorage.app',
authorize: async (req) => {
const header = String(req.headers.authorization ?? '')
if (!header.startsWith('Bearer ')) return false
try {
await getAuth().verifyIdToken(header.slice(7))
return true
} catch {
return false
}
},
}))

Mount it with app.all, not app.post. A browser on another origin sends an OPTIONS request before the POST, and the handler has to answer it. With app.post that request never reaches the handler, and the browser blocks the log.

bucket holds source maps and attachments. Responses are 204 written (also for a CORS OPTIONS preflight), 400 malformed payload, 401 gate refused, 405 not a POST and 500 for anything else. The client treats a non-2xx as a throw.

A framework that wraps Node’s objects, such as Hono, needs a small adapter:

src/server.ts
import { Hono } from 'hono'
import { createHttpLogHandler } from '@dasasian/firebase-structured-logger/functions'
const app = new Hono()
const logHandler = createHttpLogHandler({ authorize: async (req) => isSignedIn(req) })
app.on(['POST', 'OPTIONS'], '/log', async (c) => {
const headers: Record<string, string> = {}
let status = 200
let body: string | undefined
await logHandler(
{
method: c.req.method,
headers: Object.fromEntries(c.req.raw.headers),
body: await c.req.json().catch(() => null),
},
{
get statusCode() { return status },
set statusCode(v: number) { status = v },
setHeader: (name, value) => { headers[name] = value },
end: (b) => { body = b },
},
)
return new Response(body ?? null, { status, headers })
})

logFunction is any async function, so a fetch works. Throw on a non-2xx so the logger knows the send failed.

src/main.ts
import { initLogger } from '@dasasian/firebase-structured-logger/client'
import { auth } from './config/firebase'
export const logger = initLogger({
appId: 'my-app',
releaseId: import.meta.env.VITE_RELEASE_ID ?? 'dev',
logFunction: async (payload) => {
const res = await fetch('https://api.example.com/log', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await auth.currentUser?.getIdToken()}`,
},
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error(`log rejected: ${res.status}`)
},
})

A callable gets Firebase’s token check for free. An HTTP endpoint gets no check. If it is open, anyone who sends a request adds to your Cloud Logging bill. No default is safe here, so there is no default.

authorize is a gate, not an identity check. The handler never reads request.auth, and the userId on a client entry is self-reported either way. The gate keeps strangers out. A gate that throws counts as a rejection.

If something in front of the server already did that work, such as a VPC, an API gateway or IAM, say so at the call site:

src/server.ts
import { createHttpLogHandler } from '@dasasian/firebase-structured-logger/functions'
export const logHandler = createHttpLogHandler({ authorize: 'unauthenticated' })

The backend logger writes from any route. Import the log functions and call them:

src/server.ts
import { logInfo, logError } from '@dasasian/firebase-structured-logger/functions'
app.post('/orders', async (req, res) => {
try {
await placeOrder(req.body)
logInfo('order placed', { orderId: req.body.orderId })
res.sendStatus(201)
} catch (err) {
logError(err, { orderId: req.body.orderId })
res.sendStatus(500)
}
})

withLogging is a Cloud Functions tool, so there is no request scope here. Pass the labels on each call. In production the backend writes WARNING and above by default, so the logInfo above is dropped unless you set minSeverity: 'INFO' on initLogger.

  • Body parsing is yours. Mount express.json(), or your framework’s equivalent, before the handler.
  • CORS defaults to *, matching cors: true on the callable. Pass allowOrigin to name your origin. A browser cannot send cookies to a wildcard.
  • firebase-functions is not needed. Each entry is written as one line of JSON, which Cloud Run’s log agent parses into the same Cloud Logging fields a function’s entry would have.
  • Trace correlation needs nothing from you on Cloud Run. The handler reads X-Cloud-Trace-Context or traceparent and asks the metadata server for the project id once, so a request’s entries group with Cloud Run’s own request log. Elsewhere, such as GKE or a VM, set GOOGLE_CLOUD_PROJECT. Without it the trace id is written but does not join the platform’s request log.
  • firebase-admin is optional. Storage is only needed for older releases’ source maps and for attachments. Without firebase-admin, name the bucket with bucket on the handler or configureAttachments({ bucket }), and the service’s own credentials are used.

You do not need one. Run npx fsl upload-sourcemaps --bucket= --backend=./backend --embed-sourcemaps, and the current release’s maps go out inside your deploy. The empty --bucket= is on purpose, and Source maps says why.

The cost is older releases. Only the deployed release can be resolved, so an error from a previous release arrives minified, and attachments are dropped. The entry is still written. With neither firebase-admin nor a bucket name, the log says so once, at the first lookup. Source maps are covered in Source maps.

Made by Dasasian