🎉

Hono + Microsoft Azure FunctionsでContextのLogを出力してみたメモ

2024/08/25に公開

概要

前回にAzure FunctionsでHonoを動かした。
今回はログを開始時と終了時に出そうと考えている。

Hono Azure Functionsで紹介されている方法だとcontext.logが使えないので対応を考えた。

ソースコード

context.logを何故使いたいのか

ログを出すだけなら、console.logでも可能。

MSのドキュメントではcontext.logが推奨されている。

そこで、ログの出力にどんな差がでるのか試してみる。
applications.insightsのtraceログで出力される項目を確認対象とした。
結果、context.logのほうが出力される項目が多いことが分かった。
特にoperation_Idの有無がトレーサビリティに関わると思われる。

共通で出力される項目

項目
timestamp [UTC] 2024/8/24 17:13:09.793
message Hello World
severityLevel 1
itemType trace
client_Type PC
client_IP 0.0.0.0
cloud_RoleName <Function名>
cloud_RoleInstance XXXXXX-XXXXXXXXXXXXX
appId <UUID>
appName /subscriptions/<サブスクリプションID>/resourcegroups/<リソースグループ名>/providers/microsoft.insights/components/<インサイト名>
iKey <UUID>
sdkVersion azurefunctions: 4.34.2.2
itemId <UUID>
itemCount 1
_ResourceId /subscriptions/<サブスクリプションID>/resourcegroups/<リソースグループ名>/providers/microsoft.insights/components/<インサイト名>

context.logのみに出力される項目

項目
operation_Name httpTrigger
operation_Id hogehoge
operation_ParentId piyopiyo
client_City Sibuya
client_StateOrProvince Tokyo
client_CountryOrRegion Japan

出力が違う項目

項目
customDimensions JSON
項目 console.log context.log
LogLevel Information Information
HostInstanceId moga moga
ProcessId 61 61
Category Host.Function.Console Function.httpTrigger.User
prop__{OriginalFormat} Hello World -
prop__MS_AzureFunctionsRequestID - hogehoge
InvocationId - hogepiyo

Honoでcontext.logを使用する

Honoのenv.bindingsの機能を利用し、env.AZURE_FUNCTIONS_CONTEXTにFunctionsのcontextを格納する。
Middlewareとして開始と終了のログを出すことにした。

apps/api/src/functions/httpTrigger.ts
import { app, HttpResponseInit } from '@azure/functions';
import honoApp from '@api/app';
import { HttpRequest, InvocationContext } from '@azure/functions';
import type { ReadableStream } from 'node:stream/web';
import { ExecutionContext } from 'hono';

const newAzureFunctionsResponse = (response: Response): HttpResponseInit => {
  const returnBase = {
    status: response.status,
    headers: headersToObject(response.headers),
  };
  if (!response.body) return returnBase;
  return { ...returnBase, body: streamToAsyncIterator(response.body) };
};
const streamToAsyncIterator = (readable: ReadableStream<Uint8Array>) => {
  const reader = readable.getReader();
  return {
    next() {
      return reader.read();
    },
    return() {
      return reader.releaseLock();
    },
    [Symbol.asyncIterator]() {
      return this;
    },
  } as AsyncIterableIterator<Uint8Array>;
};

type LoopableHeader = {
  forEach: (callbackfn: (value: string, key: string) => void) => void;
};

function headersToObject(input: LoopableHeader): Record<string, string> {
  const headers: Record<string, string> = {};
  input.forEach((v, k) => (headers[k] = v));
  return headers;
}

const newRequestFromAzureFunctions = (request: HttpRequest): Request => {
  const hasBody = !['GET', 'HEAD'].includes(request.method);

  return new Request(request.url, {
    method: request.method,
    headers: headersToObject(request.headers),
    ...(hasBody ? { body: request.body, duplex: 'half' } : {}),
  });
};

type FetchCallback = (
  request: Request,
  env: Record<string, unknown>,
  executionCtx?: ExecutionContext,
) => Promise<Response> | Response;

function azureHonoHandler(fetch: FetchCallback) {
  return async (request: HttpRequest, _context: InvocationContext) => {
    return newAzureFunctionsResponse(
      await fetch(newRequestFromAzureFunctions(request), {
        ...process.env,
        AZURE_FUNCTIONS_CONTEXT: _context,
      }),
    );
  };
}

app.setup({
  enableHttpStream: true,
});

app.http('httpTrigger', {
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  authLevel: 'anonymous',
  route: 'api/{*proxy}',
  // 第2引数のcontextはenv.AZURE_FUNCTIONS_CONTEXTに格納する
  handler: azureHonoHandler(honoApp.fetch),
});
apps/api/src/app.ts
import characters from './routes/characters';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import transactionTest from './routes/transactionTest';
import { MiddlewareHandler } from 'hono/types';
import { AppContext } from './types';

const route = new Hono<AppContext>()
  .route('/characters', characters)
  .route('/test', transactionTest)
  // loggerの使い方サンプル
  .get('/echo', async (c) => {
    const { logger } = c.get('services');

    // ログ出力の順番確認用に少し待つ
    await new Promise((resolve) => {
      setTimeout(resolve, 500);
    });
    const test = process.env.HOGE;
    logger.log(`echo: ${test}`);
    return c.json({ echo: test });
  });
;

const startEndLogMiddleWare: MiddlewareHandler<AppContext> = async (
  c,
  next,
) => {
  const logger = new Logger(c.env.AZURE_FUNCTIONS_CONTEXT);
  c.set('services', { logger });
  logger.log(`func start: ${c.req.url}`);
  await next();
  logger.log(`func end: ${c.req.url}`);
};

const app = new Hono<AppContext>()
  .use(startEndLogMiddleWare)
  .use(
    '/api',
    cors({
      origin: '*',
      allowHeaders: ['Content-Type'],
      allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    }),
  )
  .route('/api', route);
export default app;
export type AppType = typeof app;
apps/api/src/types.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { InvocationContext } from '@azure/functions';

type Bindings = {
  AZURE_FUNCTIONS_CONTEXT: InvocationContext;
};
type Logger = {
  log(...args: any[]): void;
  trace(...args: any[]): void;
  debug(...args: any[]): void;
  info(...args: any[]): void;
  warn(...args: any[]): void;
  error(...args: any[]): void;
};

export type AppContext = {
  Bindings: Bindings;
  Variables: {
    services: {
      logger: Logger;
    };
  };
};

参考

Honoを使い倒したい2024

Discussion