Classi開発者ブログ

教育プラットフォーム「Classi」を開発・運営するClassi株式会社の開発者ブログです。

社内npm packageをRenovateで更新する方法

こんにちは、 Classi でソフトウェアエンジニアやってます koki です。

この記事では、 Renovate によって Classi の社内向け npm package を自動アップデートさせるために行った設定についてまとめます。

概要

Classi では、社内向けの共通ライブラリや Docker イメージなどを GitHub Packages で管理しています。 この GitHub Packages の利用により、それらのプライベートなパッケージを社内の様々なシステムから安全且つ効率的に利用することを実現しています。
例えば、今年の 6 月にプレスリリースが出された学習トレーニング機能を裏で支えているコンテンツ管理システムで利用している GraphQL Schema は GitHub Packages の npm registry でプライベートな npm package として管理しています。 こちらについて詳しくは下記記事をご参照ください。

また、各システムの GitHub リポジトリでは依存ライブラリの自動アップデートのために Renovate を利用しています。 下記記事では Renovate が作成したライブラリアップデートの PR のマージを自動化した仕組みについても解説していますので、よければこちらもご参照ください。

Renovate はデフォルトではプライベートな GitHub Packages への参照権限を持っていません。 そのため、社内向け npm package に依存しているリポジトリの Renovate Dashboard には次のような Warning が表示されます。

Renovate failed to look up the following dependencies: Failed to look up npm package <パッケージ名>

上記の Warning を解消するための Renovate の設定をしていきます。

設定手順

Renovate 用の Personal Access Token ( Classic ) を発行する

Renovate にプライベートな GitHub Package を読み取らせるためには、まず Personal Access Token を発行する必要があります。 2023 年 10 月時点で Fine-grained token は GitHub Packages に対応していないため、トークンの種類は Classic を選択する必要があることに注意してください。

必要な権限は次の通りです。

  • repo
  • read:packages

Personal Access Token を暗号化する

先ほど発行した Personal Access Token は暗号化した上で設定ファイルに埋め込む必要があります。 下記ページを使用して Personal Access Token を暗号化することができます。 ( ここで暗号化したトークンは Renovate のみが復号できます。詳細については後述の「暗号化済みトークンをハードコーディングして大丈夫なの?」をご参照ください。 )

Renovate Encrypt の画面

項目
Organization GitHub Organization 名
Repository (Optional) 暗号化済みトークンを使用できるリポジトリ名
省略した場合は Organization 内の全てのリポジトリから暗号化済みトークンを使用できる
Raw Value Renovate 用の Personal Access Token ( Classic ) を発行する」手順で発行した Personal Access Token

それぞれ入力して Encrypt をクリックすると Encrypted value に暗号化されたトークンが表示されます。 この値は後ほど Renovate の設定に使用するため、控えておきます。

Renovate の設定を作成

続いて Renovate の設定ファイルを作成します。 次のような内容で renovate.json を作成します。

{
  "hostRules": [
    {
      "matchHost": "https://npm.pkg.github.com/",
      "hostType": "npm",
      "encrypted": {
        "token": "<ENCRYPTED_PERSONAL_ACCESS_TOKEN>"
      }
    }
  ],
  "npmrc": "@<ORGANIZATION_NAME>:registry=https://npm.pkg.github.com/"
}

この設定を GitHub に push して Renovate を再実行することで、 Renovate により GitHub Packages の npm package を自動アップデートできるようになります。

実際に作られた Classi の社内 npm package の更新 PR

設定ファイルを分ける場合

先ほど作成した設定ファイルは renovate.json ではなく別のファイルに分けたい場合もあると思います。 例えば、「Renovate の設定を作成」手順で作成した JSON を renovate-github-packages.json という名前で保存する場合、 renovate.jsonextends に次のように追記します。

 {
   "$schema": "https://docs.renovatebot.com/renovate-schema.json",
   "extends": [
     // ...その他の設定,
+    "local><ORGANIZATION_NAME>/<REPOSITORY_NAME>:renovate-github-packages",
   ],
   // ...その他の設定
 }
  • <ORGANIZATION_NAME> : GitHub Organization 名。
  • <REPOSITORY_NAME> : リポジトリ名。

こうすることで renovate.json から renovate-github-packages.json を読み込むことができます。 設定ファイルの共有方法について詳しくは下記ドキュメントをご参照ください。

Appendix

これらの設定は必要なくなるかも ( GitHub Packages を使用している場合 )

GitHub Packages への参照権限を自動で設定する機能が Renovate に追加されたようです。 これにより、この記事で紹介した手順は不要になるかもしれません。

ただしこれはあくまで GitHub Packages でパッケージを管理している場合なので、 npmjs.org 等のレジストリを使用してプライベートなパッケージを管理している場合は引き続きこの記事と同じような手順で適切な認証トークンを暗号化して設定ファイルに埋め込む必要があります。

暗号化済みトークンをハードコーディングして大丈夫なの?

先ほど紹介した下記ページを使用することで、 Personal Access Token は Renovate の公開鍵を使用して暗号化されます。

この方法で暗号化されたトークンは Renovate のみが復号できる = Renovate 用途でしか使えません。 暗号化済みトークンを使用できる Organization と Repository も制限できるので、暗号化済みトークン自体はもし漏洩しても大きな問題はありません。 特に Organization は必須項目なので、少なくとも組織外のリポジトリから使うことはできません。

とはいえ、万が一 Renovate 側に悪い人がいたり秘密鍵漏洩等のセキュリティインシデントが発生したりした場合は Personal Access Token が漏洩する可能性はもちろんあります。 そうなったら、かなり広い権限 ( 特に repo Scope ) を持っている Personal Access Token なので結構大変です。

なので「Renovate には専用の Personal Access Token を割り当てて他のものを流用しない」等の運用を行い、万が一何かがあったときにすぐ Revoke できるようにしておくことが大切です。

参考

Lambda Extensionと自家版OpenTelemetry Collector

こんにちは・こんばんは・おはようございます、エンジニアのid:aerealです。

以前、 実践OpenTelemetry - Classi開発者ブログ で紹介したようにOpenTelemetryを監視フレームワークとして導入しています。

前回の記事ではECSサービスで動くGoで書かれたWebアプリケーションへ導入しましたが、今回はAWS Lambdaの関数からOpenTelemetryを用いてDatadogにトレースを送るための試行錯誤について紹介します。

LambdaからDatadogにトレースを送りたい

AWS Lambdaは小〜中程度のタスクを大量に実行することに向いたFaaSです。

汎用性・柔軟性という点ではECSなどのコンテナワークロードと比べると実行環境などに設けられた制約がやや多いですが、その代わりに最適化された実行環境で実行されるのでうまくワークロードと噛み合えばまさに安い・早い・うまいの三拍子が揃うとても魅力的なサービスです。

ClassiでもLambdaを活用しており、本番環境で使うからには当然監視も充実させたいです。

冒頭の記事でも触れたようにフレームワークとしてはOpenTelemetryを、SaaSとしてはDatadogを活用しているのでLambda関数の分散トレースも当然OpenTelemetryを用いてDatadogへ送りたいです。

ECSサービスでホストされるアプリケーションの場合はOpenTelemetry Collectorをサイドカーコンテナとして実行し、アプリケーションからはサイドカーを通じてDatadogなりへシグナルを送ればよいのですが、既に述べたようにLambdaの実行環境は特殊で、Lambdaのエコシステムに沿った準備をしないといけません。

幸いながら現在Lambda ExtensionというLambdaのライフサイクルに沿って実行されるサイドカーのような仕組みが整えられており、一般的なコンテナワークロードとまったく同じ体験とはいきませんが、アプリケーション本体と補助的なソフトウェアをうまく分離することはできます。

まさにDatadog agentがLambda Extensionとして提供されていたり、OpenTelemetry CollectorがLambda Extensionとして提供されていたりと、これらがそのまま使えるとよさそうです。

しかし、それで済めば筆者は筆をとってこの記事を書いていないでしょう。つまり我々がやりたいことを実現するには一筋縄ではいかないのが現状なのです。

具体的には Lambda関数からOpenTelemetry SDKを用いてDatadogへトレースを送る という3つの要素要求すべてを満たすにはありものでは賄えないのです。

言い換えると (「Lambda関数から」という点はともかく) SDKかDatadogのどちらかを諦めれば実現できます。

しかしOpenTelemetry SDKさえ使えば複数のライブラリを使わなくて良いという魅力に惹かれて導入・移行したので今更後戻りはしたくありません。Lambdaの監視だけDatadogではない別のSaaSを使うというのもいざ障害などに遭遇した時に不安が残ります。

なので:

  • Lambda関数から
  • OpenTelemetry SDKのみを用いて
  • Datadogへトレースを送る

……という3点すべてを満たすべく格闘していきます。

Lambda Extensionについて

改めてLambda Extensionについて述べます。

Lambda Extensionは関数本体とは別にLambdaのライフサイクルイベントごとに実行される補助的なソフトウェアです。

たとえば関数の起動時に初期化処理を走らせ、関数の呼び出しごとにデータを集めるなどし、関数がコールド状態に移行する時に集積したデータを送信する……といったことができます。

リリースと同時にいくつかのSaaSがLambda Extensionを発表しており、その中にはDatadogのextensionもあります。

関数の中の所定のディレクトリに実行ファイルとして配置され、それがLambdaによって都度呼び出されるというかたちになっているため、デーモンのようには振る舞えないのでデーモンとして実行することを想定しているソフトウェアは実装を変える必要があります。

AWS Distro for OpenTelemetry (ADOT) とopentelemetry-lambdaについて

OpenTelemetry Collector (以下、OTel Collector) をLambda Extensionとして実行する方法も既にあり、それがAWS Distro for OopenTelemetry (ADOT)です。

ADOTはAWSがサポートするOTel Collectorのディストリビューションです。LambdaやECSなど各環境で公式にサポート・推奨するexporterなどをあらかじめ有効にしたビルド済みのOTel Collectorです。 また、ビルド済みのLambda ExtensionもADOTプロジェクトの一部として提供されています。

ADOTが提供するLambda ExtensionはExtensionのプロトコルを喋りOTel Collectorを動かすラッパとして振る舞うopentelemetry-lambdaという別リポジトリにパッチを当ててビルドしたものです。

opentelemetry-lambdaはopentelemetry org.がオーナーであり、Lambda Extensionプロトコルを喋る実装はopentelemetryプロジェクトが責任を持ち、配布するソフトウェアとしてはAWSが別にオーナーシップを持っているというモデルのようです。

至れり尽くせりなプロジェクトがあるのでその恩恵に与れたらよいのですが、なんとこのADOTにはdatadogexporterが含まれていません。 つまり、ADOTを使ってDatadogへトレースやメトリクスを送れません。

X-Rayなど競合するサービスを提供しているという事情も想像できますが、それ以外にdatadogexporterの初期化処理に時間がかかりLambdaのInitフェイズでタイムアウトするというissueが過去に報告されており、それも関係しているのかもしれません。

いずれにせよADOTを用いてDatadogへトレースを送ることはできないことがわかりました。

Lambda ExtensionおよびOpenTelemetry Collectorの自前ビルド

ADOTはopentelemetry-lambdaにパッチを当ててビルドしているだけと説明しました。ということはdatadogexporterを使えるようパッチを当ててビルドすればLambdaからDatadogへトレースを送れそうです。

OTel Collectorはビルド済みのディストリビューションを使うほか、自分でディストリビューションを作ることもでき、その方法やツールも公式にまとめられています

最終的に作りたいのはLambda Extensionなので「『datadogexporterを含むOTel Collector』を含むLambda Extensionをビルドする」ことが目標です。

言葉にするとややこしいですが、要はADOTであてているパッチを変えてビルドし、その結果をホストできればよいです。

そしてできあがったopentelemetry-lambdaの差分が次のURLです: https://github.com/open-telemetry/opentelemetry-lambda/compare/main...aereal:opentelemetry-lambda:support-datadog-exporter

続いて変更内容について詳しく説明します。

datadogexporterを追加

今回の本命となる変更です。

datadogexporterがLambda Extension向けにそもそもコンパイルできないといった事情はないので、単にimportしてファクトリメソッドに渡すだけです。

Parameters Store confmap providerを追加

実用上、必要になって追加した変更です。

LambdaはECSなどと異なり環境変数のデータソースとして直接Parameters StoreやSecrets Managerなど秘匿情報を安全に伝達できるサービスがサポートされておらず、関数の設定に直接値を記述するほかありません。

datadogexporterを使うにはOTel Collectorの設定にDatadogのAPI keyを渡す必要があり、これは当然ながら秘匿情報です。

Lambda関数で秘匿情報を扱うにはParameters StoreやSecrets Managerなどへ関数内から直接アクセスするほかありませんが、今回サービスを通じて秘匿情報を得たいのは関数本体ではなくLambda Extension内で動くOTel Collectorです。

設定ファイルにParameters Storeへアクセスするコードを書けるわけもないのでconfmap providerを介して取得できるようカスタマイズしました。

confmapとは環境変数やHTTPエンドポイントなど、外部データソースから値を設定に埋め込む仕組みです。 各データソースごとの実装がconfmap providerと呼ばれます。

OTel Collectorは標準で環境変数のconfmap providerが組み込まれているので、 ${env:DD_API_KEY} のように書くとOTel Collectorの起動時に環境変数を参照して設定が補完されます。

Parameters Storeからパラメータを取得し、値を展開するconfmap providerを実装しそれを使うことで設定ファイルに ${ssm:/path/to/datadog-api-key} のような記述をして、適切なIAM許可を与えておけばOTel CollectorにAPIキーを渡せます。

使ったconfmap providerの実装はこちらです: https://github.com/aereal/otel-confmap-provider-awsssm

Lambda Extensionをビルドし、GitHub Packagesでホストする

https://github.com/aereal/otel-collector-dist

最後にopentelemetry-lambdaのforkをビルドします。ADOTを参考にExtensionをGitHub Actionsでビルドするリポジトリを別に用意しました。

opentelemetry-lambdaのfork自体は随時上流と同期しないといけないので、差分を減らすために別リポジトリにしています。

ビルドしたExtensionを含むコンテナイメージもビルドし、GitHub Packagesでホストするようにしました。 GitHub Actionsと連携する上で便利ですし、更に社内用ビルドを社内のinternal/privateなリポジトリから参照する際のアクセス許可も簡単に設定できます。

むすび

Datadogへトレースを送れるようカスタマイズしたOpenTelemetry CollectorをLambda Extensionとしてビルドする方法とOpenTelemetry Collectorにまつわるエコシステムについて紹介しました。

筆者もそうだったのですが、初見では様々な概念が登場するため混乱しがちですが落ち着いて整理するときれいに依存が分離されていますし、それぞれのエコシステムも十分な情報がまとまっているため、自家ビルドも簡単にできました。

自家版ビルドを作り運用することはもちろん運用の負荷が増すことになるので、いずれ上流で解決できたらいいなと思いますしそのように働きかけたいと思いますが、それはそれとしてOpenTelemetry Collectorのエコシステムの理解を深めるいいきっかけになりました。

みなさまもぜひ自家版ビルドをしてみてはいかがでしょうか。

ライブラリのアップデートを自動化した仕組みの紹介

こんにちは!学習動画・Webテストの開発を行っています エンジニアの daichi (id:da1chi24) です。 この記事では、最近チームで導入したライブラリアップデートを自動化した仕組みとその経緯について紹介します。

なぜ自動化しようと思ったか

サービスを開発するだけではなく、日々の運用も必要です。

その運用業務の1つとして、ライブラリのアップデートがあります。 これはサービスを運用する上では大切なことではありますが、日々ライブラリアップデートのPRをさばき続けるのも大変です。

その時間をできるだけ減らし、その分空いた時間をユーザーへの価値提供や将来の投資に充てるために、今回の自動化の仕組みを作成しました。

この辺りの話は以前勉強会でLTしたことがありますので、興味があればご覧ください。

作ったもの

前置きは長くなりましたが、凝ったものを作ったわけではありません。

作成したものはライブラリアップデートの PR を 自動マージする GitHub Acitons の workflow です。 既に稼働しており、今まで20件以上のライブラリが自動でマージされました。

導入したリポジトリはバックエンドアプリとフロントエンドアプリをモノレポで管理しています。 それぞれのアプリで自動マージする要件を変えていますが、やることは大体同じです。 プロダクションに影響がないライブラリに対して、CI が通れば github-actions[bot] で Approve してからマージするワークフローになっています。

このCIの要件はバックエンドアプリ と フロントエンドアプリで以下のように決めています。

  • バックエンドアプリの workflow
  • 対象:dependabot が作成する PR
  • 自動マージの要件:lintとテスト が通り development dependency group に属すること

バックエンドアプリの workflow のソースコード(一部抜粋)

name: Dependabot Automation
on:
  pull_request:
    paths:
      - 'Gemfile.lock'

permissions:
  pull-requests: write
  contents: write
  issues: write
  repository-projects: write
  packages: read

jobs:
  ci_and_automerge:
    if: ${{ github.actor == 'dependabot[bot]' }}
    runs-on: 'ubuntu-latest'
    timeout-minutes: 30
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Install Ruby and gems
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.1
          bundler-cache: true
      - name: Run lint
        run: bundle exec rubocop -P
      - name: Run tests
        run: bundle exec rspec
      - name: Dependabot metadata
        id: metadata
        uses: dependabot/fetch-metadata@v1.6.0
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - name: Approve & enable auto-merge for Dependabot PR
        if: |
          (steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor') &&
          steps.metadata.outputs.dependency-type == 'direct:development'
        run: |
          gh pr review --approve "$PR_URL"
          gh pr edit "$PR_URL" -t "(auto merged) $PR_TITLE"
          gh pr merge --auto --merge "$PR_URL"
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          PR_TITLE: ${{ github.event.pull_request.title }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  • フロントエンドアプリの workflow
  • 対象:renovatebot が作成するPR
  • 自動マージの要件:main と PR のブランチで build 結果の差分を比較し差分がないこと

フロントエンドアプリの workflow のソースコード(一部抜粋)

name: Renovatebot Automation
on:
  pull_request:
    paths:
      - 'yarn.lock'
permissions:
  pull-requests: write
  contents: write
  issues: write
  repository-projects: write

jobs:
  compare-static-assets:
    timeout-minutes: 10
    if: ${{ github.actor == 'renovate[bot]' }}
    runs-on: ubuntu-latest
    steps:
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      - name: Checkout current branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Install dependencies
        run: yarn
      - name: Build on current branch
        run: |
          yarn build:production
          rm -rf public/fonts/
          rm -rf public/img/
      - name: Upload output file on current branch
        uses: actions/upload-artifact@v3
        with:
          name: ${{ github.sha }}-current
          path: public
      - name: Checkout main branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.sha }}
          fetch-depth: '0'
      - name: Reinstall dependencies
        run: yarn
      - name: Build on main branch
        run: |
          yarn build:production
          rm -rf public/fonts/
          rm -rf public/img/
      - name: Download current export file
        uses: actions/download-artifact@v3
        with:
          name: ${{ github.sha }}-current
          path: out-current
      - name: Compare build assets
        run: |
          diff -rq out-current public > result.txt || true
      - name: Comment results of diff
        uses: actions/github-script@v6
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            const fs = require('fs')
            const result = fs.readFileSync('result.txt', 'utf8')
            const commentBody = result ?
              `静的アセットのビルド結果に差分があります👀 <p>${result}</p>`
              : '静的アセットのビルド結果に差分はありません🎉'
            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: commentBody
            })
      - name: Approve & enable auto-merge for Renovatebot PR
        run: |
          diff -rq out-current public
          gh pr review --approve "$PR_URL"
          gh pr edit "$PR_URL" -t "(auto merged) $PR_TITLE"
          gh pr merge --auto --merge "$PR_URL"
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          PR_TITLE: ${{ github.event.pull_request.title }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

補足 GitHub Actions のワークフローで PR の作成や Approve を行うためには、事前に Organization で許可をする必要があります。

GitHub Actions による pull request の作成または承認を回避する

フロントエンドアプリに 「development dependency group に属すること」という要件を入れていないのは、ライブラリがdevDependenies に属してもプロダクションに影響与える場合があるためです。 例えば babel や webpack などのビルドツールはバージョンが上がることで、ビルド結果が変わる可能性があります。

そのためプロダクションに影響がないのはビルド結果の差分を検知することだと判断しました。

作る際に詰まったところ

ここではこのワークフローを稼働させるまでに詰まったところについて共有します。

code owner が指定されていると自動マージができない

branch protection rules で code owner のレビューを必須にしている場合は、github-actions[bot] で Approve しても要件を満たすことができないため、マージできません。 また 2023年10月現在では bot を code owner に登録することもできません。

GitHub Apps can’t be used in CODEOWNERS – that’s not supported. https://github.com/orgs/community/discussions/23064

この問題は code owner を使わないことで解決しました。

code owner は ディレクトリやファイルごとにレビュワーを制限することができますが、導入したリポジトリでは運用するチームが限定されているため必要ありません。 リポジトリ単位でレビュワーを制限するなら Collaborators の設定 で 運用チームだけにApprove できる権限を絞れば良いと言う判断になりました。

どうしても code owner を使いたい場合は、machine user を利用する方法があります。 machine user は Organization に対して1つ作成できます。

GitHub アカウントの種類

作成した machine user を code owner に指定し、PATの権限を利用し gh コマンドで Apporve をすると code owner の要件を満たすことができます。

どのような方法を使うかは各チームやリポジトリの要件に応じて決めると良いでしょう。

権限が不足して GitHub Container Repository からイメージを pull できない

チームではGitHub Actions のワークフロー上でテスト環境を準備するために、 GitHub Container Repository から専用のイメージを pull して使用しています。 使用するイメージは社内でプライベートに公開しているイメージなので、アクセストークンで認証する必要がありました。

このアクセストークンに GITHUB_TOKEN を使ったのですが、イメージが pull できませんでした。 原因は GITHUB_TOKEN の permission の scope が不足していることでした。

workflow で使い捨ての GITHUB_TOKEN という認証トークンが発行されますが、ワークフローを動かすためには permissions キーで必要な scope を設定する必要があります。

以下のように指定することで解決しました。

permissions:
  packages: read

jobs:
  ci:
    services:
      db:
        image: ghcr.io/classi/xxx
        credentials:
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

まとめ

今回はチームで導入したライブラリアップデートの自動化の仕組みとその経緯について紹介しました。 特別なものではありませんが、少しの工夫で日々の時間を節約できたのは良い成果だと感じています。

今後もこのような開発・運用を改善する取り組みについて紹介していきたいと思います。

データサイエンス×教育:取り組みから実感した教育分野のPoCのおもしろさ

こんにちは。データサイエンティストの石井です。 今回は、複数の問題を組合せた問題の集合(以降、問題集という)を推薦するアイデアのPoCに取り組みました。今回の取り組みの中ではこのアイデアが有効かの判断には至りませんでしたが、具体的にどのような取り組みを行ったのか、またそこで直面した教育分野のPoC特有のおもしろさについて紹介します。

なぜ問題集の推薦に取り組むのか

2023年5月31日に公開したプレスリリースのとおり、新しい学び支援機能としてAI搭載の「学習トレーニング」機能の提供を始めました。

参考:生徒の自律性とAIによる個別最適な学びを両立する「学習トレーニング」機能を6月にリリース

この機能に搭載されているAIは、生徒一人一人に合わせた問題を1問ずつ推薦するものです。 また、このAIは問題に取り組むごとにそれまでの解答データから「その生徒にある問題を出題した時に何%の確率で正解できそうか」を計算し、その予測正答確率の値にもとづき、難しすぎず易しすぎない問題を推薦します。 このAIを搭載した「学習トレーニング」機能では、特に取り組む問題数の上限を設けず、何問でも推薦された問題に取り組むことができます。 一方、私の学生時代を振り返ると、ページ数や学習時間をあらかじめ決めて学習に取り組んでいた記憶があります。そこで、もしあらかじめ問題数が決まっているのであれば、その問題数の問題を選定し、学習に取り組む方がより効果の高い学習ができるのではないか、と考えました。 このような考えから、「生徒の実力に合わせて問題選定された問題集の推薦は、既存の1問ずつの推薦より、高い学習効果が得られるか」という仮説を立てました。

問題集編成のアプローチ

数理最適化技術を用いたモデルで、Classiに搭載されている大量の問題からいくつかの問題を選択し、問題集を編成していきます。編成したい問題集の設問数に応じて、どの難易度の問題をどの順序で出題するかを考慮します。例えば、ある生徒には対象生徒の実力より難しい問題を多く出題する場合(下図の例①)、また、ほかのある生徒には対象生徒の実力より易しい問題を多く出題する場合(下図の例②)など、設問と難易度の関係(以降、理想の難易度分布という)については、さまざまなケースが考えられます。この理想の難易度分布と選ぶ問題の難易度の差を最小化する整数計画問題を解くことで問題集を編成します。

理想の難易度分布例

モデルの詳細

定式化

与えられた問題集合を  I とし、編成する問題集の設問番号の集合を  J とします。 変数  x_{ij} を問題  i を問題集の設問  j に採用するなら1、非採用なら0となる2値変数とします   ( i \in I, j \in J, |I| \ge |J|)。 理想の難易度分布  f(j) (j \in J) 、問題  iの難易度  d_{i} を用いて、制約: |f(j) - d_{i}|x_{ij} \leq \delta ( \forall i \in I , \forall j \in J) を満たす  \delta の最小化を目的関数とします。 また、そのほかの制約には以下があります。

ある設問に1つの問題を割り当てる制約:

 \displaystyle \sum_{i \in I} x_{ij} = 1 ( \forall j \in J )

ある問題はたかだか1つの設問にしか割り当てられない制約:

 \displaystyle \sum_{j \in J} x_{ij} \le 1 ( \forall i \in I )

以上のことを整理すると、解くべき整数計画問題は次のようになります。

 \displaystyle
\begin{eqnarray}
& \text{Minimize}
& \delta \nonumber
\\
& \text{s.t.}
& |f(j) - d_{i}|x_{ij} \leq \delta \left( \forall i \in I , \forall j \in J \right) \nonumber
\\
&& \sum_{i \in I} x_{ij} = 1 \left( \forall j \in J \right) \nonumber
\\
&& \sum_{j \in J} x_{ij} \le 1 \left( \forall i \in I \right) \nonumber
\\
&& x_{ij} \in \left\{ 0, 1 \right\} \left( \forall i \in I, \forall j \in J \right). \nonumber
\end{eqnarray}

マッチングのアルゴリズムにもとづく高速な解法

快適なサービス提供のために、ある単元に含まれる問題数  |I| = 1853 , 代表的なテストの設問数  |J| = 10の設定で、0.7秒以内に解を求めることを目標にしました。 しかし、先の定式化を汎用ソルバーCBCで解くと約26秒を要し、さらに問題数や設問数が多い場合には解を求めることができませんでした。 そのため、マッチングのアルゴリズムにもとづく高速な解法を検討しました。 この解法では、全設問への割り当てが存在するような難易度のずれの許容値  \delta について、その最小値を二分探索で求めることを考えます。 与えられた  \delta に対し、全設問への割り当てが存在するか否かを判定するには、まず、与えられた  \deltaで2部グラフを構成します。 次に、最大マッチングを求め、そのサイズが設問数と一致していれば、全設問への割り当てが存在することになり、そうでなければ所望の割り当ては存在しないことになります。  \delta が最小のときの全設問への割り当ては、上で求めた最大マッチングの結果から求めることができます。

マッチングのアルゴリズムにもとづく高速な解法

この解法を用いて、設問数を10問で固定し問題数を変動させるパターンと、問題数を1853問で固定し設問数を変動させるパターンの2つの実験を行いました。 この解法では、下表のように、想定する問題数・設問数に対して0.7秒未満で求めることができ、さらに想定以上の問題数・設問数でも解を求めることができました。

数値実験結果

PoCの実施

今回のPoCで、本来明らかにしたいことは「生徒の実力に合わせて問題選定された問題集の推薦は、既存の1問ずつの推薦より、高い学習効果が得られるか」、さらに、学習効果が高い場合に「どのような生徒に、どのような理想の難易度分布で編成した問題集が学習効果を最大化するのか」です。

学習効果があるか判断するためには、ある程度の期間でこの問題集を用いて学習に取り組んでもらう必要があります。また、その期間中はほかの学習コンテンツを使用せず、この問題集のみで学習してもらう必要があります。日々、学校や塾などで所定のカリキュラムで学習する生徒に対して、この検証環境を作ることは現実的ではありません。

そこで今回は、数時間の検証でこの学習効果を測定できないか、検討しました。 以下のタイムスケジュールで検証を行い、事前・事後テストの結果の差から学習効果を測ります。

  • 事前テスト:30分(10問)
  • 問題集での学習①:約45分(10問)
  • 問題集での学習②:約45分(10問)
  • 事後テスト:30分(10問)

学習①では事前テストの結果から、学習②では学習①の結果から、問題集の難易度を決定します。また、理想の難易度分布は、下図のような、実力より易しい問題から始まり、中盤は実力より少し難しい問題を多く出題し、終盤に実力より難しい問題を出題する分布としました。

PoCで使用した理想の難易度分布例

事前テストと事後テストで出題される問題は異なる等質なテスト(ある受験者に対し、同一の評価ができるテスト)を使用します。なお、この事前テスト・事後テストも整数計画問題として定式化し、編成しました。学習効果がある場合は、短期間で結果に出るように事前・事後テストと問題集の出題範囲を1つの単元に絞りました。今回は「数学A-場合の数」で実施しました。学習効果は、事前・事後テストの解答の正誤データをもとに、IRT(項目反応理論)にもとづき推定した能力値の増減で判定します。

検証の結果、1人目の能力値は 1.87→0.72と減少し、2人目の能力値は-0.33→1.02と増加しました。1人目の減少の理由はいくつか考えられます。

  • 事前・事後テストの問題数が不足しており、正確に計測できなかった可能性
  • 事前・事後テストが等質なテストではなく、正確に計測できなかった可能性
  • 「数学A-場合の数」は全列挙で正答できてしまい、検証対象の単元として不適切であった可能性
  • 学習時間が90分では足りず、実際に学習効果がなかった可能性

一方、2人目は正誤結果に違和感なく、能力値も増加しました。今回の事前検証から、短期間での検証で学習効果を計測できる可能性はあると感じています。しかし、教育におけるPoCの難しさを痛感し、短期間で学習効果を計測するPoCを行うには改善が必要だと思われます。

2人目の被験者の正誤結果

教育におけるPoCのおもしろさ

教育分野の学習コンテンツのPoCにおいて、その学習コンテンツの有効性を十分に判断するためには、学習効果を測る必要があります。しかし、上で述べたように学習効果を測ることは容易ではありません。学習効果を測る際には以下の観点のバランスをみて総合的に決定していく必要があります:

  1. 検証期間: 学習効果があるかを判断するために必要十分な学習期間を設定しなければなりません。期間が短すぎる場合は学習効果が十分に判断できず、反対に期間が長すぎる場合は後述の検証環境の構築が難しくなります。

  2. 出題範囲: 教科や単元(例:因数分解や場合の数など)の出題範囲が必要十分でなければなりません。出題範囲を広げた場合はそれだけ検証期間を要することとなり、出題範囲を狭めた場合は設定した単元に依存した検証結果となってしまいます。

  3. 被験者と検証環境: 純粋な学習効果を計測するためには、検証期間中、被験者を学校や塾などのほかの環境での学習から切り離さなければなりません。

  4. 学習効果の測定方法: 学習効果を測定する一般的な手法である「テスト」を使用するか、また、テストで行う場合には事前・事後テストが等質でなければなりません。

これらは、教育分野の学習に関するPoCにおいて重要な観点であり、決定するのは非常に難しいです。しかし、このような難しさが教育分野におけるデータサイエンスのおもしろさでもあります。教科や単元により、問題設定からモデルの構築までまったく異なる教育分野はデータサイエンスの宝庫です。

最後に

ClassiにおけるPoCの取り組みと、教育分野のPoC特有のおもしろさについて紹介しました。今回の数理最適化技術を応用したモデルの開発は、学校の先生や生徒との継続的な対話とモデルの改善を繰り返し行い、さらに、東京理科大学の池辺淑子准教授、西田優樹助教、法政大学の鮏川矩義准教授から数理最適化技術に関してアドバイスをいただき、共同で開発を進めました。このように、Classiでは生徒により良い学習体験を提供するために、学校の先生・生徒、そして学術機関と協力し、多くの取り組みを積極的に行っています。また、Classiではこのほかにも生成AIを用いたコンテンツ制作やClassiにおける学習データでの成績評価など、さまざまな取り組みを行っています。教育分野におけるプロダクト開発に興味をお持ちの方は、ぜひ採用ページをご覧ください。皆様からのご応募をお待ちしています。

https://hrmos.co/pages/classi/jobs

実践OpenTelemetry

こんにちは・こんばんは・おはようございます、エンジニアのid:aerealです。

この記事では筆者が開発に参加しているサービスの監視フレームワークをOpenTelemetryへ移行した際の体験を紹介します。

OpenTelemetryとは

OpenTelemetry is an Observability framework and toolkit designed to create and manage telemetry data such as traces, metrics, and logs.

What is OpenTelemetry?

サイトの説明にある通り分散トレースやメトリクス、ログなどの指標を扱う監視フレームワークです。

OpenTracingやOpenCensusなどを継承・統合したプロジェクトと言うと合点がいく方も多いのではないでしょうか。

OpenTelemetryは、指標や属性などの意味論やプロトコルを定めた仕様、それら仕様を実現する各言語の実装、そしてプロトコルに従いデータを処理・送受信するCollectorの計3つからおおまかに成り立っています。

アプリケーションにSDKを組み込み、サイドカーもしくはゲートウェイとして配置したCollectorに対し各指標を送り、Collectorは設定で記述された通りに指標を加工し外部サービスへ送信するという流れになっています。

アプリケーションはSDKを介してOpenTelemetryプロトコルでCollectorに送信することだけに関心を持てばよく、その指標がどのように外部サービスへ送信されるかはCollectorへ任せられるという点が各ベンダのSDKをそれぞれ利用する場合に対する利点です。

またCollectorはバッファリングだけではなく、属性 (attributes) と呼ばれる指標のメタデータを追加・加工・削除する仕組みや、属性をもとに送信先を変えたり、送信された指標を集積してカウンタ値を生成するといった高度な仕組みも備えています。

Collectorは公式に提供されるリリースビルドのほか、組み込むコンポーネントをカスタマイズしてビルドする方法が提供されており、組織内部で共通して利用したいコンポーネントの組み込みが比較的簡単になっています。 この点に関してもまた機会を改めて紹介したいと思います。

Classiで採用した理由

当社で新規開発したサービスはAWSのマネージドサービスを積極的に取り入れており、コードを書いて構築するアプリケーションとマネージドサービスの境界は曖昧になってきているくらい対等な存在になっています。

refs. dron: クラウドネイティブなcron代替の紹介 - Classi開発者ブログ

そんな複数のコンポーネントからなるアプリケーションを監視する上で分散トレーシングは欠かせません。

Classiでは監視ソリューションとしてDatadogを導入・活用しています:

分散トレーシングもDatadog APMを活用しています。実際、先の記事で紹介したdronをはじめとしたすべてのClassiのサービスではDatadogのSDKを用いてAPMへトレースを送っていました。

一方、Step FunctionsなどのAWSのマネージドサービスは分散トレーシングに対応していますが、トレースの収集先はAWS X-Rayのみです。 トレースをDatadog APMのみに送っているとX-Rayのみに存在するマネージドサービスのトレースと断絶してしまいます。

仕方がなくアプリケーションはAWS X-Ray SDKとDatadog APM SDKの 両方 を導入し、X-RayとDatadog APMの両方へトレースを送ることにしていました。 しかし、トレースのメタデータ追加などあらゆる実装が二度手間になり、実装の抜け漏れも起きやすくなります。

実際、アプリケーションの不調を調べるためにAPMを見たらDatadog APMにはほしいメタデータを送っていなかったということが起き、ほとほと嫌気が差したので二重の計装をやめるべく調査をはじめ、OpenTelemetryに行き着きました。

なおX-Rayへ統一するという選択肢は、アラートの設定など監視全般をDatadogに寄せる方針であったこと・それを差し引いてもDatadogのほうが使い勝手が良かったことから、検討していません。

アプリケーションへの導入

監視SDKとしてOpenTelemetryへ移行することに決めたあとにやることは至って単純で、ドキュメントに従いライブラリを導入し、OpenTelemetry Collectorをサイドカーに追加し、動作確認をします。

移行中の基本的な動作確認する際は、実際にX-RayやDatadogに送るよりもローカルで起動したZipkinを送信先に設定したCollectorを動かすと素早くトライアンドエラーを繰り返すようになりとても重宝しました。

もちろん各exporterの変換処理が挟まること・それら変換が設定によって異なることから、最終的には実際にX-RayやDatadogに送った上で確認すべきですが、たとえばそもそも収集したいスパンが送られているかとか、きちんと属性を追加できているかとか、そういった基本的な確認はZipkinでも十分です。

属性の管理

OpenTelemetryは属性 (attributes) と呼ばれるメタデータがトレースやメトリクスなどの各指標に紐付けられます。

これらはX-Rayではアノテーション・Datadogではタグにそれぞれ変換されます。

属性は指標の情報量を増やし監視を大いに助けてくれますが、アプリケーション全体で統一しておかないといざという時に使えません。

そこで属性を設定ファイルで管理し、属性を注入する実装は設定ファイルから生成した関数だけが行えるようにすることで、アプリケーション全体で使われている属性を統一することにしました。

以下に実際の設定ファイルの例を紹介します:

属性の設定ファイル (telemetry.yml) 例

---
# yaml-language-server: $schema=./telemetry.schema.json
attributes:
  db_migration.is_dirty: { type: bool }
  db_migration.step: { type: int }
  git.commit.sha:
    type: string
    description: |
      現在動いているコミットハッシュを表す属性名。
      Datadogのactive commit linkingに使う。

      refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
  git.repository_url:
    type: string
    description: |
      サービスのソースコードがホストされているリポジトリを指す属性値。
      Datadogのactive commit linkingに使う。

      refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
measurements:
  test_import_count: { type: Int64Gauge }

またスキーマは以下の通りです:

telemetry.schema.json

{
  "type": "object",
  "properties": {
    "attributes": {
      "$ref": "#/definitions/attributes"
    },
    "measurements": {
      "$ref": "#/definitions/measurements"
    }
  },
  "definitions": {
    "deprecationDescription": {
      "type": "object",
      "properties": {
        "reason": {
          "type": "string"
        }
      },
      "required": [
        "reason"
      ]
    },
    "attributeValueType": {
      "enum": [
        "string",
        "bool",
        "int",
        "int64"
      ]
    },
    "attributeDefinition": {
      "type": "object",
      "properties": {
        "type": {
          "$ref": "#/definitions/attributeValueType"
        },
        "deprecated": {
          "$ref": "#/definitions/deprecationDescription"
        },
        "description": {
          "type": "string"
        }
      },
      "required": [
        "type"
      ]
    },
    "attributes": {
      "type": "object",
      "patternProperties": {
        ".+": {
          "$ref": "#/definitions/attributeDefinition"
        }
      }
    },
    "measurementType": {
      "enum": [
        "Float64Counter",
        "Float64Gauge",
        "Float64Histogram",
        "Float64UpDownCounter",
        "Int64Counter",
        "Int64Gauge",
        "Int64Histogram",
        "Int64UpDownCounter"
      ]
    },
    "measurementDefinition": {
      "type": "object",
      "properties": {
        "type": {
          "$ref": "#/definitions/measurementType"
        }
      }
    },
    "measurements": {
      "type": "object",
      "patternProperties": {
        ".+": {
          "$ref": "#/definitions/measurementDefinition"
        }
      }
    }
  }
}

  • attributes.$name.type: 属性の型。stringやintなどOpenTelemetryの仕様に従う
  • attributes.$name.description: 属性の説明。省略可能で、生成されたコードのドキュメントにも含まれる

VS Codeにredhat.vscode-yamlを入れておくと、ローカルに配置したJSON Schemaを読み込んでフィールドの補完を行ってくれます。

以下に上記ファイルから生成されたGoのコードを載せます:

生成されたGoのコード

// KeyDbMigrationIsDirty is an attribute key that means "db_migration.is_dirty".
var KeyDbMigrationIsDirty = attribute.Key("db_migration.is_dirty")

// AttrDbMigrationIsDirty returns a new attribute that named "db_migration.is_dirty".
func AttrDbMigrationIsDirty(v ...bool) attribute.KeyValue {
    switch {
    case len(v) == 0:
        return emptyKeyValue
    case len(v) == 1:
        return KeyDbMigrationIsDirty.Bool(v[0])
    default:
        return KeyDbMigrationIsDirty.BoolSlice(v)
    }
}

// KeyDbMigrationStep is an attribute key that means "db_migration.step".
var KeyDbMigrationStep = attribute.Key("db_migration.step")

// AttrDbMigrationStep returns a new attribute that named "db_migration.step".
func AttrDbMigrationStep(v ...int) attribute.KeyValue {
    switch {
    case len(v) == 0:
        return emptyKeyValue
    case len(v) == 1:
        return KeyDbMigrationStep.Int(v[0])
    default:
        return KeyDbMigrationStep.IntSlice(v)
    }
}

// KeyGitCommitSha is an attribute key that means "git.commit.sha".
//
// 現在動いているコミットハッシュを表す属性名。
// Datadogのactive commit linkingに使う。
//
// refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
var KeyGitCommitSha = attribute.Key("git.commit.sha")

// AttrGitCommitSha returns a new attribute that named "git.commit.sha".
//
// 現在動いているコミットハッシュを表す属性名。
// Datadogのactive commit linkingに使う。
//
// refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
func AttrGitCommitSha(v ...string) attribute.KeyValue {
    switch {
    case len(v) == 0:
        return emptyKeyValue
    case len(v) == 1:
        return KeyGitCommitSha.String(v[0])
    default:
        return KeyGitCommitSha.StringSlice(v)
    }
}

// KeyGitRepositoryUrl is an attribute key that means "git.repository_url".
//
// サービスのソースコードがホストされているリポジトリを指す属性値。
// Datadogのactive commit linkingに使う。
//
// refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
var KeyGitRepositoryUrl = attribute.Key("git.repository_url")

// AttrGitRepositoryUrl returns a new attribute that named "git.repository_url".
//
// サービスのソースコードがホストされているリポジトリを指す属性値。
// Datadogのactive commit linkingに使う。
//
// refs. https://docs.datadoghq.com/integrations/guide/source-code-integration/?tab=dockerruntime#tag-your-telemetry
func AttrGitRepositoryUrl(v ...string) attribute.KeyValue {
    switch {
    case len(v) == 0:
        return emptyKeyValue
    case len(v) == 1:
        return KeyGitRepositoryUrl.String(v[0])
    default:
        return KeyGitRepositoryUrl.StringSlice(v)
    }
}

ご覧の通り設定ファイルに記述した description が関数のコメントに含まれています。

最後に以下のようにアプリケーション内の任意のファイルから go.opentelemetry.io/otel/attribute を用いて設定ファイルで管理されていない属性の追加ができないようgolangci-lintを設定します:

linters:
  disable-all: true
  enable:
    - forbidigo
linters-settings:
  forbidigo:
    forbid:
      - "^attribute[.]"

厳密には attribute. で始まる式が違反となるもので想定よりファジーですが実用上困っていないのでこのままとします。

最後にコード生成に使ったスクリプトを以下に示します:

generate-signals/main.go

package main

import (
    "bytes"
    _ "embed"
    "errors"
    "flag"
    "fmt"
    "go/format"
    "os"
    "path/filepath"
    "sort"
    "strconv"
    "strings"
    "text/template"
    "unicode"

    "gopkg.in/yaml.v3"
)

var (
    //go:embed src.gotpl
    tmplBody string

    pkg        string
    typeName   string
    doFormat   bool
    configPath string
    outPath    string

    errPkgRequired = errors.New("-pkg is required")
    errOutRequired = errors.New("-out is required")
)

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "%+v\n", err)
        os.Exit(1)
    }
}

func run() error {
    flag.Usage = func() {
        fmt.Fprintf(os.Stderr, "Usage of %s:\n", filepath.Base(os.Args[0]))
        flag.PrintDefaults()
    }
    flag.Parse()
    if pkg == "" {
        return errPkgRequired
    }
    if outPath == "" {
        return errOutRequired
    }
    parsed, err := template.New("src.gotpl").
        Funcs(template.FuncMap{"quote": strconv.Quote, "prefixLines": prefixLines}).
        Parse(tmplBody)
    if err != nil {
        return fmt.Errorf("template.Parse: %w", err)
    }
    cfg, err := readConfig(configPath)
    if err != nil {
        return fmt.Errorf("readConfig: %w", err)
    }
    attrs := make([]*attribute, 0, len(cfg.Attributes))
    for name, def := range cfg.Attributes {
        def := def
        a := &attribute{Name: name, Type: def.Type, Description: def.Description}
        if def.Deprecation != nil {
            a.DeprecationReason = def.Deprecation.Reason
        }
        attrs = append(attrs, a)
    }
    measurements := make([]*measurement, 0, len(cfg.Measurements))
    for name, def := range cfg.Measurements {
        def := def
        measurements = append(measurements, &measurement{Name: name, DataType: def.DataType})
    }
    sort.Slice(attrs, func(i, j int) bool { return attrs[i].Name < attrs[j].Name })
    sort.Slice(measurements, func(i, j int) bool { return measurements[i].Name < measurements[j].Name })
    out := new(bytes.Buffer)
    data := struct {
        Package       string
        Attributes    []*attribute
        Measurements  []*measurement
        TypeName      string
        SingletonName string
    }{
        Package:       pkg,
        Attributes:    attrs,
        Measurements:  measurements,
        TypeName:      typeName,
        SingletonName: "Measurements",
    }
    if err := parsed.Execute(out, data); err != nil {
        return fmt.Errorf("template.Execute: %w", err)
    }
    body := out.Bytes()
    if doFormat {
        var err error
        body, err = format.Source(body)
        if err != nil {
            return fmt.Errorf("format.Source: %w", err)
        }
    }
    if err := os.WriteFile(outPath, body, 0600); err != nil {
        return fmt.Errorf("os.WriteFile(%s): %w", outPath, err)
    }
    return nil
}

type attribute struct {
    Name              string
    Type              string
    DeprecationReason string
    Description       string
}

func (a *attribute) inputType() (elmType string, isSlice bool) {
    t, ok := strings.CutPrefix(a.Type, "[]")
    return t, ok
}

func (a *attribute) ElemType() string {
    et, _ := a.inputType()
    return et
}

func (a *attribute) AttributeConstructor() string {
    cn, _ := a.inputType()
    return strings.ToUpper(string(cn[0])) + cn[1:]
}

func (a *attribute) AttributeSliceConstructor() string {
    return a.AttributeConstructor() + "Slice"
}

func (a *attribute) GoType() string {
    return a.Type
}

func (a *attribute) Identifier() string {
    b := new(strings.Builder)
    shouldUpNextRune := true
    for _, r := range a.Name {
        switch {
        case shouldUpNextRune:
            b.WriteRune(unicode.ToUpper(r))
            shouldUpNextRune = false
        case r == '_' || r == '.':
            shouldUpNextRune = true
        default:
            b.WriteRune(r)
        }
    }
    return b.String()
}

type measurement struct {
    Name     string
    DataType string
}

func (m *measurement) QualifiedName() string {
    return "app." + m.Name
}

func (m *measurement) FieldName() string {
    b := new(strings.Builder)
    shouldUpNextRune := true
    for _, r := range m.Name {
        switch {
        case shouldUpNextRune:
            b.WriteRune(unicode.ToUpper(r))
            shouldUpNextRune = false
        case r == '_':
            shouldUpNextRune = true
        default:
            b.WriteRune(r)
        }
    }
    return b.String()
}

func (m *measurement) SDKReturnType() string {
    switch m.DataType {
    case "Int64Gauge":
        return "Int64ObservableGauge"
    case "Float64Gauge":
        return "Float64ObservableGauge"
    default:
        return m.DataType
    }
}

type deprecationDescription struct {
    Reason string `yaml:"reason"`
}

type attributeDefinition struct {
    Type        string                  `yaml:"type"`
    Deprecation *deprecationDescription `yaml:"deprecated"`
    Description string                  `yaml:"description"`
}

type measurementDefinition struct {
    DataType string `yaml:"type"`
}

type telemetryConfig struct {
    Attributes   map[string]*attributeDefinition   `yaml:"attributes"`
    Measurements map[string]*measurementDefinition `yaml:"measurements"`
}

func readConfig(cfgPath string) (*telemetryConfig, error) {
    f, err := os.Open(cfgPath)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    cfg := new(telemetryConfig)
    if err := yaml.NewDecoder(f).Decode(cfg); err != nil {
        return nil, err
    }
    return cfg, nil
}

func prefixLines(prefix, s string) string {
    return prefix + strings.ReplaceAll(s, "\n", "\n"+prefix)
}

func init() {
    flag.StringVar(&pkg, "package", "observability", "package name")
    flag.StringVar(&typeName, "type", "MeasurementDefinition", "type name")
    flag.StringVar(&configPath, "config", "etc/telemetry.yml", "config file path")
    flag.BoolVar(&doFormat, "format", true, "whether do format")
    flag.StringVar(&outPath, "out", "", "output file path")
}

src.gotpl

package {{ .Package }}

// Code generated by tools/gen-telemetry-signal/main.go; DO NOT EDIT.

import (
  "go.opentelemetry.io/otel/attribute"
  "go.opentelemetry.io/otel/metric"
)

var emptyKeyValue attribute.KeyValue

{{ range $attr := .Attributes }}
// Key{{ $attr.Identifier }} is an attribute key that means {{ $attr.Name | quote }}.
{{- with $attr.Description }}
//
{{ . | prefixLines "// " }}{{ end }}
{{- with $attr.DeprecationReason }}
//
{{ (. | printf "Deprecated: %s") | prefixLines "// " }}{{ end }}
var Key{{ $attr.Identifier }} = attribute.Key({{ $attr.Name | quote }})

// Attr{{ $attr.Identifier }} returns a new attribute that named {{ $attr.Name | quote }}.
{{- with $attr.Description }}
//
{{ . | prefixLines "// " }}{{ end }}
{{- with $attr.DeprecationReason }}
//
{{ (. | printf "Deprecated: %s") | prefixLines "// " }}{{ end }}
func Attr{{ $attr.Identifier }}(v ...{{ $attr.ElemType }}) attribute.KeyValue {
  switch {
  case len(v) == 0:
    return emptyKeyValue
  case len(v) == 1:
    return Key{{ $attr.Identifier }}.{{ $attr.AttributeConstructor }}(v[0])
  default:
    return Key{{ $attr.Identifier }}.{{ $attr.AttributeSliceConstructor }}(v)
  }
}
{{ end -}}

// measurements

func {{ .SingletonName }}() {{ .TypeName }} {
  return __singleton_{{ .SingletonName }}
}

var __singleton_{{ .SingletonName }} = {{ .TypeName }}{
  {{- range $measurement := .Measurements -}}
    {{ $measurement.FieldName }}: {{ $measurement.QualifiedName | quote }},
  {{- end -}}
}

type {{ .TypeName }} struct {
  {{ range $measurement := .Measurements }}
    {{ $measurement.FieldName }} string
  {{- end -}}
}

{{ range $measurement := .Measurements }}
func Measure{{ $measurement.FieldName }}(meter metric.Meter) (metric.{{ $measurement.SDKReturnType }}, error) {
  return meter.{{ $measurement.SDKReturnType }}({{ $measurement.QualifiedName | quote }})
}
{{ end -}}

むすび

AWS X-RayおよびDatadog APMへトレースを送る仕組みとしてOpenTelemetryを採用・移行した経緯と具体的なエピソードを紹介しました。

他にもOpenTelemetryを活用する上で様々な取り組みをしているので今後も紹介したいと思います。

テスト自動化〜運用編〜

こんにちは!Classi QAチームの池田です。

今回は、Classiで取り組んでいるテスト自動化の運用についてご紹介します!

弊社ではテスト自動化ツールにAutifyを使用しており、テストを自動化したお話についてはこちらの記事で紹介しておりますので、よろしければご覧ください!

運用の取り組み①定期実行

弊社のQAエンジニアは、各開発チームに「専任QA」として配属されています。 私が専任QAとして所属している開発チームの担当領域は「設定・登録」です。

自動化を進める前は定期的なリグレッションテストが行われていなかったため、自動化したテストを定期実行し、品質の担保に貢献していきたいと考えていました。

また、長い時間を要してしまう手動でのリグレッションテストを自動化することで、検証時間の削減にも繋げていきたいと考えました。

自動テストを作成し始めてからは定期的に開発チームに進捗を共有していたのですが、「できてるシナリオからどんどん回していけば?」と提案していただいたので、完成したシナリオから定期実行を始めました。

定期実行に含まれるシナリオの一部

「設定・登録」では、先生や生徒の情報を登録したり、授業や時間割を設定することができます。 そのため自動化のシナリオ内では各種設定や情報を操作することが多いのですが、その際シナリオの順序性により不都合が出てしまうことがありました。

例えば、シナリオを並列実行することにより、あるシナリオの情報変更操作が原因で並列実行しているシナリオが失敗してしまう、途中で実行失敗しシナリオ内で削除されるべきデータが削除できず後続のシナリオに影響が出て失敗してしまう、などの事象が立て続けに起こりました。

そのため、実行は並列から直列に変更、他に影響を及ぼす可能性のあるシナリオの実行順序を後回しにする、などの改善を行いました。

定期実行に関する資料の一部

成果としては、定期実行により障害を検知することができました。

開発チームは不定期にリリースを行っており、ライブラリのアップグレードなどのQAを通さない簡素なリリースもあります。 そのリリースで影響範囲の考慮が一部漏れており障害が出てしまったのですが、定期実行で検知しユーザーからの問い合わせが入る前に対応することができました。

運用の取り組み②開発のテスト工程での自動テスト活用

リリース時のQA検証としても、自動テストの活用を開始しました。 QA検証の際には、影響範囲である機能をリグレッションテストとして実行しています。

自動テストを実行している間に手動項目を検証できるので検証時間短縮に繋がっています。自動テストを活用し始めてから、月10時間ほど検証時間を短縮できた時期もありました。

また、リリース内容によっては自動テストだけで完了できるものもあります。 その場合には自動テストを実行しながら別作業ができるので、より効率的に仕事ができるようになりました。

運用の取り組み③シナリオ改善作業

自動テストを作成後、既存機能に修正が入った場合や、新機能や画面に新しい文言が追加された場合などの状況に応じて修正や改善作業を行っています。

文言を確認するステップを自動テストに入れている場合、UIの僅かな変化でも自動テストが失敗してしまうことがあるので注意が必要です。

また開発の都合上、別の開発チームが担当領域の改修等を行うことがあるため、自動テストの修正要否を判断するために、他のチームのリリース情報を随時キャッチアップするようにしています。

運用していくにあたり、自動テストの改善も必要です。 内容によっては実行に時間がかかってしまったり、よく失敗してしまうテストもあります。 どうしたら実行時間をより短縮できるか、どうしたらテストを安定させられるか、という観点で改善していくことが多いです。自分達だけで解決が難しそうな場合は、ツールのカスタマーサポートに問い合わせて助けていただくこともあります。

例えば、自動テスト内で情報の入力や選択をする際、要素を探索する時間が長くなってしまう場合があります。そういった場合、CSSセレクターを利用して要素を選択し時間短縮を行う方法があります。ただし、CSSセレクターの中にテストのたびに変わってしまうclassの値などが入っているとテストが失敗してしまうため、注意が必要です。

CSSセレクターを指定したステップの例

運用の取り組み④ナレッジ蓄積

自動テストを運用するにあたり、ナレッジの蓄積も重要となります。

自動テストを作成から運用までしていると、情報や知識が属人化しやすくなります。自分以外のメンバーが対応することになってもいいようにナレッジを蓄積していく必要があると感じました。

自動テストシナリオの一例

以下、ドキュメントを残す際の観点の一例です。

  • 自動テスト運用の概要
  • シナリオ一覽
  • データ編集内容
  • 使用している環境/アカウント
  • 注意事項

このドキュメントを見れば、仮に自分が不在の場合でも他のメンバーが運用の内容を把握し、対応できるようにしています。

ドキュメントの一部

運用の取り組み⑤開発メンバーへの自動テスト解放

そして、今年度から取り組んでいる運用が「開発メンバーへの自動テスト解放」です!

昨年度までに全ての開発チームに対するテスト自動化がほぼ完了しました。 次のステップとして、QAチーム内のみで管理していた自動テストを開発チームにも解放し、開発チームも自動テストを使ってテスト工程で検証できる体制作りを進めています。

狙いとしては開発チームのリリース頻度向上、およびQAチームの工数削減を目的としています。

開発チームが自動テストで検証することで、QAチームに依頼する工数が削減できリリースしやすい状況になること、且つQAチームも検証にかかる工数を削減できるようになることを想定しています。

現在、一部のチームから解放するトライアルを実施しています。解放対象は既にQAが作成した自動テストの実行のみで、自動テストのメンテナンスや作成は引き続きQAメンバーが対応していますが、今後の状況によっては解放範囲も変わってくるかなと思います。

現在はトライアルの段階ですが、開発メンバーへヒアリングを実施して運用の改善に努めていく予定です。

運用フローの一部

さいごに

以上、Classi QAチームのテスト自動化の取り組みをご紹介しました!

どの運用もまだまだ試行錯誤している段階のため、課題やフィードバックを基により良い運用になるよう努めていきたいと思います!

プロダクト本部の誕生と組織変更の狙い

みなさん、こんにちは。プロダクト本部 本部長のid:tetsuro-ito です。Classiでは、8月に組織体制を再編して新たな組織体制に移行しましたので、今日はその内容と狙いを説明します。 以前のブログでも組織に関する話を紹介しており、その継続的な話になるため、あらかじめ参考記事として紹介しておきます。

なぜ組織変更をしたか

昨年の記事でチームトポロジーを参考に組織編成を考えた話を紹介しました。当時も念頭においていたのはプロダクトの開発をよりスムーズに行えるようにし、事業目標を達成しにいくことが狙いとしてありました。2022年度のClassi社内の認識として、2020年のインシデント・高負荷障害を乗り越え、サービスもきちんと安定稼働してきたことから、いわゆるサバイバルモードを抜け出し、ラーニングモードの中で事業目標を達成しにいくつもりでした。

しかし、半年間そうした組織で運用して行った結果、狙いとは裏腹にあまりうまくいきませんでした。ClassiではICT教育の環境変化に伴って、学習系のサービスに注力をしていく戦略を取っています。2023年度にはこれまでの学習機能を刷新して、新たに学習トレーニングという機能をリリースしていく想定でしたが、その開発に黄色信号が灯ってしまう状態でした。そのため方針を転換し、まずはきちんとこの学習トレーニングを開発し、お客様に届けられるような体制へシフトしました。その甲斐もあって、今期は無事に機能のリリースができています。

また、以前よりClassiではマトリクス組織という組織形態を採用していました。エンジニアやデザイナー、マーケターなどの職能を束ねる組織とClassiの各機能を担当するファンクションチームの縦横の組織でした。職能ごとに切り分けた結果、デザイナーやデータ系の組織などを誕生させることができ、メリットもあった一方で指揮系統が2箇所あることで、事業目標に向けた動きが難しかったり、情報連携にも課題があるなど、いくつかの課題もありました。

どのように組織を変更したか

組織変更のbefore/after

今回はClassiのプロダクトに関わる組織を大きく再編しました。上記の図の通り、これまでディレクターやデザイナーの職能を統括していたプロダクトデザイン本部(プロデザ本部)とエンジニアやデータサイエンティストを統括していた開発本部、プロダクトマーケティングに携わっていたプロダクトマーケティング部(PMM部)を一つの本部に統合し、それをプロダクト本部と名付けました。プロダクト本部は3つの部署からなる本部で学習PMF部、Growth部、プラットフォーム部の3つの部署で構成されています。

学習PMF部

まず、学習PMF部についてです。PMFとはProduct Market Fitの略語で、プロダクト開発やマーケティングの分野で用いられる用語です。前述したようにClassiでは、新たな学習体験を顧客へ届けるために学習トレーニング機能をリリースしています。この学習トレーニング機能をきちんと開発し、市場に受け入れられ、活用していくことが主なミッションとなります。この部に所属する開発グループとして、学習トレーニング機能を開発するチームや、学習コンテンツを搭載する機能を開発するチーム、コンテンツを作成し搭載するチーム、個別最適化学習のレコメンドエンジンを開発するチームなどが所属しています。

Growth部

次はGrowth部についてです。こちらの由来もグロースハックなどのグロースで既存のサービスを育てていく意味合いがあります。こちらは主にClassiの既存機能を担当する開発グループが所属しています。コミュニケーション機能やコーチング機能、サービスの利用開始の設定登録や認証の機能など、さまざまなものがあります。こちらの部署では、既存の機能を改善し、顧客によりよく利用してもらうことを主なミッションとしています。

プラットフォーム部

最後にプラットフォーム部です。こちらはその名の通り、サービスの基盤のようなものを担う組織です。主な機能としてインフラ/SREやQA(Quality Assurance)の機能を持った組織が所属しています。

以前の記事でチームトポロジーの概念を参考にしていると紹介しましたが、その観点でいうと、ストリームアラインドチームは学習PMF部とGrowth部で、プラットフォーム部はシステムプラットフォームチームの位置付けとなります。

プロダクトボードの導入 *1

プロダクトボードと横断ボード

今回、各部の意思決定の仕組みとしてプロダクトボードという仕組みを構築しました。ここはこれまでプロダクトマネージャーが担う職責でしたが、一人にかかる負担が高く、専門性も広く見切れないという課題がありました。それに対して、プロダクトマネジメントをそれぞれの職能のリーダーが分業で行うプロダクトボードという仕組みを採用し、それぞれの職能の合議をもって意思決定をしていくようにしています。

プロダクトボードの構成として、プロダクトマネジメントトライアングルを参考にしており、エンジニアリングサイドのリーダー、顧客サイドのリーダー、ビジネスサイドのリーダーを配置し、良い綱引きができるような体制です。まだまだ理想的な配置ができているかといえば、まだまだですが、ここをこれから育成し、きちんとしたプロダクトマネジメントが行える体制としていきたいと考えています。

また、これまでの課題として、ファンクションチームで構成されていたため、Classi全体のプロダクトの方向性を作り上げる組織が不明確でした。そこで今回は、それぞれのプロダクトボードの上に横断ボードという会議体を設置し、事業の計画やプロダクトロードマップの検討、それぞれの開発のモニタリングなどを検討できるようにしています。

Classiは2025年のビジョンとして、

先生とともに、学びから学ぶ仕組みを創り、ワクワクする子供を増やします

というビジョンを掲げています。今回の組織変更はこのビジョンの達成に向けて一丸となって動いていけるようにしているつもりです。今後も運用を続ける中でさまざまな困難が出てきて、都度見直すこともあるでしょうが、それも全てClassiの掲げるミッションやビジョンを達成するためです。

このような取り組みに共感いただける方は、ぜひとも当社の採用ページより話を聞きにきてください。

採用ページ

*1:プロダクトボードはエス・エム・エス社の取り組みを参考にしています

© 2020 Classi Corp.