$ cat post.metadata

ヘルススコアでチャーンを予兆検知する -- BtoB SaaSのGTMエンジニアリング実装

GTMエンジニアリングカスタマーサクセス

BtoB SaaSの顧客ヘルススコアを自動算出し、チャーン予兆を早期検知するシステムをTypeScriptで実装する。ログイン頻度、機能利用率、サポートチケット数等の指標をスコア化し、営業・CSチームに自動アラートする仕組み。

$ cat post.content | render --format=markdown

ヘルススコアでチャーンを予兆検知する -- BtoB SaaSのGTMエンジニアリング実装

Phase 3: エクスパンション

ここまでのシリーズで、SLGにおけるGTMエンジニアリングの実装を積み上げてきた。Phase 1ではGTMエンジニアの定義と技術選定を固め、Phase 2ではカスタムデモ環境CLAUDE.mdによる暗黙知コード化で商談サイクルを短縮した。

Phase 3のテーマはエクスパンションだ。新規顧客を獲得するフェーズから、既存顧客の維持と拡大を技術で支えるフェーズに入る。

その最初の実装が、ヘルススコアによるチャーン予兆検知だ。

なぜヘルススコアがSLGで重要か

新規獲得コストの構造的問題

SLGの成長モデルでは、新規顧客の獲得に大きなコストがかかる。営業チームの人件費、マーケティング費用、商談に要する時間。Vercel COOの事例でも触れた通り、SDRチームの人件費だけで年間数千万円規模になることは珍しくない。

BtoB SaaSの典型的な指標を見ると、構造がはっきりする。

指標値の目安意味
CAC(顧客獲得コスト)MRRの12-18ヶ月分1社獲得に年間売上の1-1.5年分かかる
LTV:CAC比率3:1以上が健全LTVがCACの3倍以上ないと赤字
Payback Period12-18ヶ月獲得コストの回収に1年以上

この構造において、1社のチャーン(解約)が意味するのは「12-18ヶ月分の投資がゼロになる」ことだ。100社のうち5社がチャーンすれば、5年分の獲得投資が消える。

NRR: SaaS評価の最重要指標

投資家がBtoB SaaSを評価するとき、最も注目する指標の1つがNRR(Net Revenue Retention)だ。

NRR = (期首MRR + Expansion - Contraction - Churn) / 期首MRR × 100

NRRが100%を超えるとは、既存顧客からの収益が自然に成長していることを意味する。新規獲得がゼロでも事業が成長する状態だ。

NRR評価代表企業(2025時点)
130%以上Best in classSnowflake, Datadog
120-130%優秀HubSpot, Cloudflare
110-120%健全多くの上場SaaS
100-110%改善が必要-
100%未満危険信号-

NRRの改善には2つのレバーがある。Expansion(アップセル・クロスセル)を増やすか、Churnを減らすか。多くの場合、Churnを減らす方が即効性がある。チャーンした顧客を取り戻すよりも、チャーンしそうな顧客を事前に検知して対処する方が圧倒的に効率がよい。

CSチームの「肌感覚」の限界

カスタマーサクセスマネージャー(CSM)は、担当顧客の健全性を「肌感覚」で把握しているケースが多い。直近のミーティングでの反応、問い合わせの頻度、請求書の支払い遅延。しかし、この属人的な検知には3つの限界がある。

1. スケールしない

CSM1人あたりの担当顧客数は、BtoB SaaSでは20-50社が一般的だ。顧客数が200社を超えると、全顧客の状態を把握すること自体が不可能になる。

2. バイアスがかかる

声の大きい顧客、最近ミーティングをした顧客の印象が強く残る。一方で、静かに離れていく顧客(サイレントチャーン)は見逃される。利用頻度が徐々に下がっていても、問い合わせがなければCSMの注意を引かない。

3. タイミングが遅い

CSMが「危ない」と気づいた時点で、顧客はすでに他社ツールの評価を進めていることがある。解約通知が届いてから慌てて対応しても、判断を覆すのは難しい。

GTMエンジニアがCSチームに提供する価値は、この3つの限界をデータとシステムで突破することにある。

ヘルススコアの設計

5つの指標と重み付け

ヘルススコアの設計で最も重要なのは「何を計測するか」の選定だ。計測しやすい指標ではなく、チャーンとの相関が強い指標を選ぶ。

過去のチャーン事例を分析し、以下の5指標を選定した。

指標重み計測対象チャーンとの相関
ログイン頻度25%DAU/MAU比率相関0.72
コア機能利用率30%主要機能のアクティブ利用数相関0.81
API利用量15%外部連携のAPI呼び出し数相関0.58
サポートチケット15%問い合わせ頻度・未解決数相関0.63
契約更新までの日数15%更新期限のタイムプレッシャー相関0.55

コア機能利用率の重みが最大なのは、チャーン相関が最も強いからだ。ログインだけしてダッシュボードを眺めている顧客と、コア機能を業務フローに組み込んで日常的に使っている顧客では、解約リスクが全く異なる。

各指標の正規化方法

各指標を0-100の統一スケールに正規化する。正規化方法は指標ごとに異なる。

ログイン頻度(DAU/MAU比率)

DAU/MAU比率はプロダクトのスティッキネスを測る指標だ。この比率が高いほど、日常的にプロダクトを使っていることを意味する。

DAU/MAU比率 = 日次アクティブユーザー数 / 月次アクティブユーザー数

正規化:
  0.50以上 → 100(毎日使っている)
  0.30-0.49 → 60-99
  0.10-0.29 → 20-59
  0.10未満 → 0-19(ほぼ使っていない)

BtoB SaaSの場合、テナント単位のDAU/MAUを計算する。テナント内のユーザー全員のログインを合算し、テナントのライセンス数で割る。

コア機能利用率

プロダクトの価値を実現するコア機能を3-5個定義し、その利用頻度を測る。

コア機能利用率 = 直近7日間にアクティブ利用されたコア機能数 / 定義されたコア機能数

正規化:
  5/5(全機能利用)→ 100
  4/5 → 80
  3/5 → 60
  2/5 → 40
  1/5 → 20
  0/5(未利用)→ 0

「アクティブ利用」の定義は、単にページを開いたことではなく、その機能で何らかのアクションを完了したことを指す。レポート機能であれば「レポートを生成した」、ワークフロー機能であれば「ワークフローを実行した」。

API利用量

外部連携の深さを測る。APIを使って自社のシステムと統合している顧客は、切り替えコストが高いためチャーンリスクが低い。

正規化:
  月間API呼び出し数をプラン上限比率で計算
  80%以上 → 100
  50-79% → 60-99
  20-49% → 30-59
  20%未満 → 0-29

サポートチケット数

サポートチケットの解釈は注意が必要だ。問い合わせが多いこと自体は悪い信号ではない(使い込んでいる証拠でもある)。問題は未解決チケットの蓄積と、ネガティブセンチメントの問い合わせの増加だ。

正規化(逆スコア: 問題が多いほど低い):
  未解決チケット0件 + ネガティブ0件 → 100
  未解決1-2件 or ネガティブ1件 → 60-80
  未解決3件以上 or ネガティブ2件以上 → 20-59
  未解決5件以上 or エスカレーション発生 → 0-19

契約更新までの日数

更新日が近づくほどリスクが上がる。特に、利用状況が芳しくない状態で更新期限を迎えると、チャーンの決断をされやすい。

正規化:
  更新まで180日以上 → 100(余裕あり)
  90-179日 → 70-99
  30-89日 → 30-69
  30日未満 → 0-29(緊急)

スコアレンジの定義

5指標の加重平均で算出したスコアを3ゾーンに分類する。

ゾーンスコア意味アクション
Green70-100健全。利用が活発で、チャーンリスクは低い定期フォローのみ。Expansion機会を探る
Yellow40-69注意。一部の指標が低下しているCSMが2週間以内にコンタクト。利用状況のヒアリング
Red0-39危険。複数の指標が悪化している即日対応。CSマネージャー + 営業担当でアクションプラン策定

実装

モノレポ構成のpackages/gtm/health-score/にモジュールを配置する。TypeScriptモノレポの記事で設計した構成に従う。

Prismaスキーマ

prisma
// packages/gtm/health-score/prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Tenant { id String @id @default(uuid()) name String slug String @unique industry String plan String contractStartDate DateTime @map("contract_start_date") contractEndDate DateTime @map("contract_end_date") licensedUsers Int @map("licensed_users") crmAccountId String? @map("crm_account_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") usageLogs UsageLog[] healthScores HealthScore[] healthAlerts HealthAlert[] @@map("tenants") } model UsageLog { id String @id @default(uuid()) tenantId String @map("tenant_id") userId String @map("user_id") eventType String @map("event_type") featureName String? @map("feature_name") metadata Json? occurredAt DateTime @map("occurred_at") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) @@index([tenantId, occurredAt]) @@index([tenantId, eventType]) @@map("usage_logs") } model HealthScore { id String @id @default(uuid()) tenantId String @map("tenant_id") overallScore Float @map("overall_score") loginFrequencyScore Float @map("login_frequency_score") coreFeatureScore Float @map("core_feature_score") apiUsageScore Float @map("api_usage_score") supportTicketScore Float @map("support_ticket_score") renewalProximityScore Float @map("renewal_proximity_score") zone String // 'green' | 'yellow' | 'red' movingAvg7d Float? @map("moving_avg_7d") movingAvg30d Float? @map("moving_avg_30d") calculatedAt DateTime @map("calculated_at") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) @@index([tenantId, calculatedAt]) @@index([zone]) @@map("health_scores") } model HealthAlert { id String @id @default(uuid()) tenantId String @map("tenant_id") alertType String @map("alert_type") severity String // 'critical' | 'warning' | 'info' message String reasoning String? recommended String? resolvedAt DateTime? @map("resolved_at") resolvedBy String? @map("resolved_by") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) @@index([tenantId, createdAt]) @@index([severity, resolvedAt]) @@map("health_alerts") }

スコア計算エンジン

各指標の正規化関数とスコア算出をTypeScriptで実装する。

typescript
// packages/gtm/health-score/src/types.ts import { z } from 'zod' export const HealthMetricsSchema = z.object({ tenantId: z.string().uuid(), loginFrequency: z.object({ dauCount: z.number().int().min(0), mauCount: z.number().int().min(0), licensedUsers: z.number().int().positive(), }), coreFeatureUsage: z.object({ activeFeatures: z.array(z.string()), totalCoreFeatures: z.number().int().positive(), featureActions: z.record(z.string(), z.number()), }), apiUsage: z.object({ monthlyCallCount: z.number().int().min(0), planLimit: z.number().int().positive(), }), supportTickets: z.object({ unresolvedCount: z.number().int().min(0), negativeCount: z.number().int().min(0), escalatedCount: z.number().int().min(0), }), contractRenewal: z.object({ daysUntilRenewal: z.number().int(), }), }) export type HealthMetrics = z.infer<typeof HealthMetricsSchema> export const ScoreWeightsSchema = z.object({ loginFrequency: z.number().default(0.25), coreFeatureUsage: z.number().default(0.30), apiUsage: z.number().default(0.15), supportTickets: z.number().default(0.15), contractRenewal: z.number().default(0.15), }) export type ScoreWeights = z.infer<typeof ScoreWeightsSchema> export type HealthZone = 'green' | 'yellow' | 'red' export interface HealthScoreResult { readonly overallScore: number readonly loginFrequencyScore: number readonly coreFeatureScore: number readonly apiUsageScore: number readonly supportTicketScore: number readonly renewalProximityScore: number readonly zone: HealthZone }
typescript
// packages/gtm/health-score/src/normalizers.ts function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max) } function linearScale( value: number, inputMin: number, inputMax: number, outputMin: number, outputMax: number, ): number { if (inputMax === inputMin) return outputMax const ratio = (value - inputMin) / (inputMax - inputMin) return clamp(outputMin + ratio * (outputMax - outputMin), outputMin, outputMax) } export function normalizeLoginFrequency( dauCount: number, mauCount: number, licensedUsers: number, ): number { if (licensedUsers === 0) return 0 const dauMauRatio = mauCount === 0 ? 0 : dauCount / mauCount const adoptionRate = mauCount / licensedUsers const ratioScore = dauMauRatio >= 0.5 ? 100 : linearScale(dauMauRatio, 0, 0.5, 0, 100) const adoptionScore = adoptionRate >= 0.8 ? 100 : linearScale(adoptionRate, 0, 0.8, 0, 100) // DAU/MAU比率を70%、アダプション率を30%の重みで合算 return Math.round(ratioScore * 0.7 + adoptionScore * 0.3) } export function normalizeCoreFeatureUsage( activeFeatures: number, totalCoreFeatures: number, ): number { if (totalCoreFeatures === 0) return 0 const ratio = activeFeatures / totalCoreFeatures return Math.round(ratio * 100) } export function normalizeApiUsage( monthlyCallCount: number, planLimit: number, ): number { if (planLimit === 0) return 0 const utilizationRate = monthlyCallCount / planLimit if (utilizationRate >= 0.8) return 100 return Math.round(linearScale(utilizationRate, 0, 0.8, 0, 100)) } export function normalizeSupportTickets( unresolvedCount: number, negativeCount: number, escalatedCount: number, ): number { if (escalatedCount > 0) return clamp(20 - escalatedCount * 10, 0, 19) if (unresolvedCount >= 5 || negativeCount >= 2) { return clamp(30 - (unresolvedCount * 3 + negativeCount * 5), 0, 39) } if (unresolvedCount >= 1 || negativeCount >= 1) { return clamp(80 - (unresolvedCount * 10 + negativeCount * 15), 40, 80) } return 100 } export function normalizeRenewalProximity( daysUntilRenewal: number, ): number { if (daysUntilRenewal >= 180) return 100 if (daysUntilRenewal < 0) return 0 return Math.round(linearScale(daysUntilRenewal, 0, 180, 0, 100)) }
typescript
// packages/gtm/health-score/src/calculator.ts import type { HealthMetrics, HealthScoreResult, HealthZone, ScoreWeights, } from './types' import { normalizeLoginFrequency, normalizeCoreFeatureUsage, normalizeApiUsage, normalizeSupportTickets, normalizeRenewalProximity, } from './normalizers' const DEFAULT_WEIGHTS: ScoreWeights = { loginFrequency: 0.25, coreFeatureUsage: 0.30, apiUsage: 0.15, supportTickets: 0.15, contractRenewal: 0.15, } function determineZone(score: number): HealthZone { if (score >= 70) return 'green' if (score >= 40) return 'yellow' return 'red' } export function calculateHealthScore( metrics: HealthMetrics, weights: ScoreWeights = DEFAULT_WEIGHTS, ): HealthScoreResult { const loginFrequencyScore = normalizeLoginFrequency( metrics.loginFrequency.dauCount, metrics.loginFrequency.mauCount, metrics.loginFrequency.licensedUsers, ) const coreFeatureScore = normalizeCoreFeatureUsage( metrics.coreFeatureUsage.activeFeatures.length, metrics.coreFeatureUsage.totalCoreFeatures, ) const apiUsageScore = normalizeApiUsage( metrics.apiUsage.monthlyCallCount, metrics.apiUsage.planLimit, ) const supportTicketScore = normalizeSupportTickets( metrics.supportTickets.unresolvedCount, metrics.supportTickets.negativeCount, metrics.supportTickets.escalatedCount, ) const renewalProximityScore = normalizeRenewalProximity( metrics.contractRenewal.daysUntilRenewal, ) const overallScore = Math.round( loginFrequencyScore * weights.loginFrequency + coreFeatureScore * weights.coreFeatureUsage + apiUsageScore * weights.apiUsage + supportTicketScore * weights.supportTickets + renewalProximityScore * weights.contractRenewal, ) return { overallScore, loginFrequencyScore, coreFeatureScore, apiUsageScore, supportTicketScore, renewalProximityScore, zone: determineZone(overallScore), } }

日次バッチ処理

Cloudflare Workers Cron Triggerで毎日深夜にスコアを再計算する。

typescript
// packages/gtm/health-score/src/batch.ts import { PrismaClient } from '@prisma/client' import { calculateHealthScore } from './calculator' import { collectMetrics } from './metrics-collector' import { detectAlerts } from './alert-detector' import { calculateMovingAverages } from './trend' const prisma = new PrismaClient() export async function runDailyBatch(): Promise<{ readonly processed: number readonly alerts: number }> { const tenants = await prisma.tenant.findMany({ where: { plan: { not: 'churned' } }, }) let alertCount = 0 for (const tenant of tenants) { const metrics = await collectMetrics(prisma, tenant.id) const score = calculateHealthScore(metrics) const movingAverages = await calculateMovingAverages( prisma, tenant.id, score.overallScore, ) await prisma.healthScore.create({ data: { tenantId: tenant.id, overallScore: score.overallScore, loginFrequencyScore: score.loginFrequencyScore, coreFeatureScore: score.coreFeatureScore, apiUsageScore: score.apiUsageScore, supportTicketScore: score.supportTicketScore, renewalProximityScore: score.renewalProximityScore, zone: score.zone, movingAvg7d: movingAverages.avg7d, movingAvg30d: movingAverages.avg30d, calculatedAt: new Date(), }, }) const alerts = await detectAlerts(prisma, tenant.id, score, movingAverages) alertCount += alerts.length } return { processed: tenants.length, alerts: alertCount } }
typescript
// packages/gtm/health-score/src/metrics-collector.ts import type { PrismaClient } from '@prisma/client' import type { HealthMetrics } from './types' export async function collectMetrics( prisma: PrismaClient, tenantId: string, ): Promise<HealthMetrics> { const now = new Date() const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) const tenant = await prisma.tenant.findUniqueOrThrow({ where: { id: tenantId }, }) // DAU: 直近24時間のユニークユーザー数 const dauResult = await prisma.usageLog.groupBy({ by: ['userId'], where: { tenantId, eventType: 'login', occurredAt: { gte: oneDayAgo }, }, }) // MAU: 直近30日間のユニークユーザー数 const mauResult = await prisma.usageLog.groupBy({ by: ['userId'], where: { tenantId, eventType: 'login', occurredAt: { gte: thirtyDaysAgo }, }, }) // コア機能利用: 直近7日間 const coreFeatures = ['dashboard', 'report', 'workflow', 'integration', 'export'] const featureUsage = await prisma.usageLog.groupBy({ by: ['featureName'], where: { tenantId, eventType: 'feature_action', featureName: { in: coreFeatures }, occurredAt: { gte: sevenDaysAgo }, }, _count: { id: true }, }) const activeFeatures = featureUsage.map((f) => f.featureName).filter( (name): name is string => name !== null, ) const featureActions: Record<string, number> = {} for (const usage of featureUsage) { if (usage.featureName) { featureActions[usage.featureName] = usage._count.id } } // API利用量: 直近30日間 const apiCallCount = await prisma.usageLog.count({ where: { tenantId, eventType: 'api_call', occurredAt: { gte: thirtyDaysAgo }, }, }) // サポートチケット(外部APIからの取得を想定、ここではUsageLogで代替) const unresolvedTickets = await prisma.usageLog.count({ where: { tenantId, eventType: 'support_ticket', metadata: { path: ['status'], equals: 'open' }, }, }) const negativeTickets = await prisma.usageLog.count({ where: { tenantId, eventType: 'support_ticket', metadata: { path: ['sentiment'], equals: 'negative' }, }, }) const escalatedTickets = await prisma.usageLog.count({ where: { tenantId, eventType: 'support_ticket', metadata: { path: ['escalated'], equals: true }, }, }) // 契約更新までの日数 const daysUntilRenewal = Math.ceil( (tenant.contractEndDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000), ) const apiPlanLimits: Record<string, number> = { starter: 10000, growth: 50000, enterprise: 200000, } return { tenantId, loginFrequency: { dauCount: dauResult.length, mauCount: mauResult.length, licensedUsers: tenant.licensedUsers, }, coreFeatureUsage: { activeFeatures, totalCoreFeatures: coreFeatures.length, featureActions, }, apiUsage: { monthlyCallCount: apiCallCount, planLimit: apiPlanLimits[tenant.plan] ?? 10000, }, supportTickets: { unresolvedCount: unresolvedTickets, negativeCount: negativeTickets, escalatedCount: escalatedTickets, }, contractRenewal: { daysUntilRenewal, }, } }

トレンド分析: 移動平均

スコアの絶対値だけでなく、変化の傾向が重要だ。スコアが60でも、2週間前は80だった顧客と、3ヶ月前から60で安定している顧客では意味が全く違う。

typescript
// packages/gtm/health-score/src/trend.ts import type { PrismaClient } from '@prisma/client' export interface MovingAverages { readonly avg7d: number | null readonly avg30d: number | null readonly trend7d: number | null // 正: 改善、負: 悪化 readonly trend30d: number | null } export async function calculateMovingAverages( prisma: PrismaClient, tenantId: string, currentScore: number, ): Promise<MovingAverages> { const now = new Date() const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) const recentScores = await prisma.healthScore.findMany({ where: { tenantId, calculatedAt: { gte: thirtyDaysAgo }, }, orderBy: { calculatedAt: 'desc' }, select: { overallScore: true, calculatedAt: true }, }) if (recentScores.length === 0) { return { avg7d: null, avg30d: null, trend7d: null, trend30d: null } } const scores7d = recentScores.filter( (s) => s.calculatedAt >= sevenDaysAgo, ) const allScoresIncludingCurrent = [currentScore, ...recentScores.map((s) => s.overallScore)] const scores7dIncludingCurrent = [ currentScore, ...scores7d.map((s) => s.overallScore), ] const avg7d = scores7dIncludingCurrent.length > 0 ? Math.round( scores7dIncludingCurrent.reduce((sum, s) => sum + s, 0) / scores7dIncludingCurrent.length, ) : null const avg30d = allScoresIncludingCurrent.length > 0 ? Math.round( allScoresIncludingCurrent.reduce((sum, s) => sum + s, 0) / allScoresIncludingCurrent.length, ) : null // トレンド: 最新スコアと期間平均の差分 const trend7d = avg7d !== null ? currentScore - avg7d : null const trend30d = avg30d !== null ? currentScore - avg30d : null return { avg7d, avg30d, trend7d, trend30d } }

チャーン予兆検知のロジック

スコアを計算するだけでは不十分だ。スコアの変動パターンからチャーンの予兆を検知し、適切なタイミングでアラートを発する仕組みが必要になる。

3つの検知パターン

typescript
// packages/gtm/health-score/src/alert-detector.ts import type { PrismaClient } from '@prisma/client' import type { HealthScoreResult } from './types' import type { MovingAverages } from './trend' import { generateAlertReasoning } from './reasoning' interface Alert { readonly alertType: string readonly severity: 'critical' | 'warning' | 'info' readonly message: string readonly reasoning: string | null readonly recommended: string | null } export async function detectAlerts( prisma: PrismaClient, tenantId: string, score: HealthScoreResult, movingAverages: MovingAverages, ): Promise<readonly Alert[]> { const alerts: Alert[] = [] // パターン1: 単純閾値 -- スコア40以下 if (score.overallScore <= 39) { const reasoning = await generateAlertReasoning(score, 'threshold_breach') alerts.push({ alertType: 'threshold_breach', severity: 'critical', message: `ヘルススコアが${score.overallScore}に低下(Red zone)`, reasoning, recommended: reasoning, }) } else if (score.overallScore <= 50) { alerts.push({ alertType: 'threshold_warning', severity: 'warning', message: `ヘルススコアが${score.overallScore}に低下(Yellow zone下位)`, reasoning: null, recommended: null, }) } // パターン2: 急落トレンド -- 7日間で20ポイント以上下落 if (movingAverages.trend7d !== null && movingAverages.trend7d <= -20) { const reasoning = await generateAlertReasoning(score, 'rapid_decline') alerts.push({ alertType: 'rapid_decline', severity: 'critical', message: `7日間でスコアが${Math.abs(movingAverages.trend7d)}ポイント下落`, reasoning, recommended: reasoning, }) } else if (movingAverages.trend7d !== null && movingAverages.trend7d <= -10) { alerts.push({ alertType: 'declining_trend', severity: 'warning', message: `7日間でスコアが${Math.abs(movingAverages.trend7d)}ポイント下落傾向`, reasoning: null, recommended: null, }) } // パターン3: コア機能の利用停止 if (score.coreFeatureScore === 0) { const reasoning = await generateAlertReasoning(score, 'core_feature_abandoned') alerts.push({ alertType: 'core_feature_abandoned', severity: 'critical', message: 'コア機能の利用が完全に停止', reasoning, recommended: reasoning, }) } else if (score.coreFeatureScore <= 20) { alerts.push({ alertType: 'core_feature_declining', severity: 'warning', message: 'コア機能の利用が大幅に低下', reasoning: null, recommended: null, }) } // アラートをDBに保存 for (const alert of alerts) { await prisma.healthAlert.create({ data: { tenantId, alertType: alert.alertType, severity: alert.severity, message: alert.message, reasoning: alert.reasoning, recommended: alert.recommended, }, }) } return alerts }

Claude APIで予兆の理由を自然言語生成

criticalアラートが発火した場合、「なぜ危険なのか」「何をすべきか」をClaude APIで自然言語生成する。AIパイプラインの記事で構築したAnthropicクライアントを再利用する。

typescript
// packages/gtm/health-score/src/reasoning.ts import Anthropic from '@anthropic-ai/sdk' import type { HealthScoreResult } from './types' const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }) type AlertType = 'threshold_breach' | 'rapid_decline' | 'core_feature_abandoned' export async function generateAlertReasoning( score: HealthScoreResult, alertType: AlertType, ): Promise<string> { const prompt = buildReasoningPrompt(score, alertType) const response = await anthropic.messages.create({ model: 'claude-sonnet-4-5-20250514', max_tokens: 512, messages: [{ role: 'user', content: prompt }], }) const text = response.content[0].type === 'text' ? response.content[0].text : '' return text.trim() } function buildReasoningPrompt( score: HealthScoreResult, alertType: AlertType, ): string { return `あなたはBtoB SaaSのカスタマーサクセスアドバイザーです。 以下のヘルススコアデータからチャーン予兆の理由を分析し、CSMへの推奨アクションを提案してください。 ## ヘルススコア詳細 - 総合スコア: ${score.overallScore}/100(${score.zone}ゾーン) - ログイン頻度: ${score.loginFrequencyScore}/100 - コア機能利用率: ${score.coreFeatureScore}/100 - API利用量: ${score.apiUsageScore}/100 - サポートチケット: ${score.supportTicketScore}/100 - 契約更新までの近接度: ${score.renewalProximityScore}/100 ## アラートタイプ: ${alertType} 以下の形式で回答してください: **予兆の理由**: スコアの内訳から読み取れるチャーンリスクの根拠を2-3文で **推奨アクション**: CSMが今すぐ取るべき具体的なアクションを3つ箇条書きで **優先度**: 72時間以内 / 1週間以内 / 2週間以内` }

AIによる推奨アクションの例を示す。

**予兆の理由**: コア機能の利用が完全に停止しており、ログイン頻度も
20/100と大幅に低下しています。顧客がプロダクトの価値を
感じられなくなっている可能性が高く、代替ツールの評価を
開始している兆候です。

**推奨アクション**:
- 48時間以内にCSMから電話で状況ヒアリングを実施
- コア機能Xのオンボーディングセッションを再実施する提案
- 直近のサポートチケット内容を確認し、未解決の課題がないか検証

**優先度**: 72時間以内

CSMが受け取るのは「スコア42」という数字ではなく、「コア機能の利用が停止している。オンボーディングを再実施すべき」という具体的なアクション提案だ。数字だけ渡してもCSMは動けない。アクションが書いてあって初めて動ける。

営業/CSチームへの通知

Slack通知

typescript
// packages/gtm/health-score/src/notifications/slack.ts import type { HealthScoreResult } from '../types' interface SlackNotificationPayload { readonly tenantName: string readonly score: HealthScoreResult readonly alertMessage: string readonly reasoning: string | null } function zoneEmoji(zone: string): string { const emojiMap: Record<string, string> = { green: ':large_green_circle:', yellow: ':large_yellow_circle:', red: ':red_circle:', } return emojiMap[zone] ?? ':white_circle:' } export async function sendSlackAlert( payload: SlackNotificationPayload, ): Promise<void> { const { tenantName, score, alertMessage, reasoning } = payload const blocks = [ { type: 'header', text: { type: 'plain_text', text: `${zoneEmoji(score.zone)} ヘルスアラート: ${tenantName}`, }, }, { type: 'section', fields: [ { type: 'mrkdwn', text: `*総合スコア*\n${score.overallScore}/100` }, { type: 'mrkdwn', text: `*ゾーン*\n${score.zone.toUpperCase()}` }, { type: 'mrkdwn', text: `*ログイン*\n${score.loginFrequencyScore}` }, { type: 'mrkdwn', text: `*コア機能*\n${score.coreFeatureScore}` }, { type: 'mrkdwn', text: `*API利用*\n${score.apiUsageScore}` }, { type: 'mrkdwn', text: `*サポート*\n${score.supportTicketScore}` }, ], }, { type: 'section', text: { type: 'mrkdwn', text: `*アラート*: ${alertMessage}`, }, }, ] if (reasoning) { blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*分析と推奨アクション*\n${reasoning}`, }, }) } await fetch(process.env.SLACK_WEBHOOK_URL!, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blocks }), }) }

HubSpotカスタムプロパティへの反映

MCP CRM連携の記事で構築したHubSpot連携基盤を活用し、ヘルススコアをCRMのカスタムプロパティに書き込む。営業がCRMを開くだけで顧客の健全性が一目でわかる状態を作る。

typescript
// packages/gtm/health-score/src/notifications/hubspot.ts import type { HealthScoreResult } from '../types' interface HubSpotUpdatePayload { readonly crmAccountId: string readonly score: HealthScoreResult readonly lastAlertMessage: string | null } export async function updateHubSpotHealthScore( payload: HubSpotUpdatePayload, ): Promise<void> { const { crmAccountId, score, lastAlertMessage } = payload const properties: Record<string, string | number> = { health_score: score.overallScore, health_zone: score.zone, health_login_frequency: score.loginFrequencyScore, health_core_feature: score.coreFeatureScore, health_api_usage: score.apiUsageScore, health_support_tickets: score.supportTicketScore, health_renewal_proximity: score.renewalProximityScore, health_last_updated: new Date().toISOString(), } if (lastAlertMessage) { properties.health_last_alert = lastAlertMessage } await fetch( `https://api.hubapi.com/crm/v3/objects/companies/${crmAccountId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`, }, body: JSON.stringify({ properties }), }, ) }

CRMにスコアを書き込むことで、営業チームが商談時に「この顧客のヘルススコアは45で低下傾向にある」という情報をCRM画面上で確認できる。更新商談の前に顧客の健康状態を把握した上で臨めるようになる。

週次ヘルスレポート

日次のアラートに加えて、週次のサマリーレポートを自動生成する。CSマネージャーが週次ミーティングで使うための材料だ。

typescript
// packages/gtm/health-score/src/reports/weekly.ts import type { PrismaClient } from '@prisma/client' export interface WeeklyReport { readonly period: string readonly totalTenants: number readonly zoneDistribution: { readonly green: number readonly yellow: number readonly red: number } readonly newAlerts: number readonly resolvedAlerts: number readonly topRisks: readonly { readonly tenantName: string readonly score: number readonly trend: number readonly primaryIssue: string }[] readonly improvements: readonly { readonly tenantName: string readonly scoreChange: number }[] } export async function generateWeeklyReport( prisma: PrismaClient, ): Promise<WeeklyReport> { const now = new Date() const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 最新のスコアを全テナント分取得 const latestScores = await prisma.healthScore.findMany({ where: { calculatedAt: { gte: sevenDaysAgo }, }, orderBy: { calculatedAt: 'desc' }, distinct: ['tenantId'], include: { tenant: true }, }) const zoneDistribution = { green: latestScores.filter((s) => s.zone === 'green').length, yellow: latestScores.filter((s) => s.zone === 'yellow').length, red: latestScores.filter((s) => s.zone === 'red').length, } // 新規アラート数 const newAlerts = await prisma.healthAlert.count({ where: { createdAt: { gte: sevenDaysAgo } }, }) // 解決済みアラート数 const resolvedAlerts = await prisma.healthAlert.count({ where: { resolvedAt: { gte: sevenDaysAgo } }, }) // リスク上位5テナント const redScores = latestScores .filter((s) => s.zone === 'red' || s.zone === 'yellow') .sort((a, b) => a.overallScore - b.overallScore) .slice(0, 5) const topRisks = redScores.map((s) => { const scores = [ { name: 'ログイン頻度', value: s.loginFrequencyScore }, { name: 'コア機能利用', value: s.coreFeatureScore }, { name: 'API利用', value: s.apiUsageScore }, { name: 'サポート', value: s.supportTicketScore }, { name: '契約更新', value: s.renewalProximityScore }, ] const worstMetric = scores.sort((a, b) => a.value - b.value)[0] return { tenantName: s.tenant.name, score: s.overallScore, trend: s.movingAvg7d ? s.overallScore - s.movingAvg7d : 0, primaryIssue: `${worstMetric.name}${worstMetric.value}と低い`, } }) return { period: `${sevenDaysAgo.toISOString().slice(0, 10)} - ${now.toISOString().slice(0, 10)}`, totalTenants: latestScores.length, zoneDistribution, newAlerts, resolvedAlerts, topRisks, improvements: [], } }

ダッシュボード

ヘルススコアを視覚化するダッシュボードをReact + Rechartsで構築する。CSチームが日常的に確認する画面だ。

テナント一覧ビュー

全テナントをスコア順に並べ、ゾーン別に色分けするリストビュー。

tsx
// packages/gtm/health-score/src/dashboard/TenantList.tsx import { useState } from 'react' import type { HealthZone } from '../types' interface TenantRow { readonly id: string readonly name: string readonly overallScore: number readonly zone: HealthZone readonly trend7d: number | null readonly contractEndDate: string readonly lastAlertAt: string | null } const ZONE_STYLES: Record<HealthZone, { bg: string; text: string }> = { green: { bg: 'bg-green-100', text: 'text-green-800' }, yellow: { bg: 'bg-yellow-100', text: 'text-yellow-800' }, red: { bg: 'bg-red-100', text: 'text-red-800' }, } function TrendIndicator({ value }: { readonly value: number | null }) { if (value === null) return <span className="text-gray-400">--</span> if (value > 5) return <span className="text-green-600">+{value}</span> if (value < -5) return <span className="text-red-600">{value}</span> return <span className="text-gray-500">{value >= 0 ? `+${value}` : value}</span> } export function TenantList({ tenants, }: { readonly tenants: readonly TenantRow[] }) { const [sortBy, setSortBy] = useState<'score' | 'trend' | 'renewal'>('score') const [filterZone, setFilterZone] = useState<HealthZone | 'all'>('all') const filtered = filterZone === 'all' ? tenants : tenants.filter((t) => t.zone === filterZone) const sorted = [...filtered].sort((a, b) => { if (sortBy === 'score') return a.overallScore - b.overallScore if (sortBy === 'trend') return (a.trend7d ?? 0) - (b.trend7d ?? 0) return new Date(a.contractEndDate).getTime() - new Date(b.contractEndDate).getTime() }) return ( <div> <div className="flex gap-4 mb-6"> <div className="flex gap-2"> {(['all', 'red', 'yellow', 'green'] as const).map((zone) => ( <button key={zone} onClick={() => setFilterZone(zone)} className={`px-3 py-1 rounded-full text-sm ${ filterZone === zone ? 'bg-gray-800 text-white' : 'bg-gray-100' }`} > {zone === 'all' ? 'All' : zone.charAt(0).toUpperCase() + zone.slice(1)} {zone !== 'all' && ( <span className="ml-1"> ({tenants.filter((t) => t.zone === zone).length}) </span> )} </button> ))} </div> <select value={sortBy} onChange={(e) => setSortBy(e.target.value as typeof sortBy)} className="px-3 py-1 border rounded text-sm" > <option value="score">スコア順(低い順)</option> <option value="trend">トレンド順(悪化順)</option> <option value="renewal">契約更新日順</option> </select> </div> <table className="w-full"> <thead> <tr className="text-left text-sm text-gray-500 border-b"> <th className="pb-2">テナント名</th> <th className="pb-2">スコア</th> <th className="pb-2">ゾーン</th> <th className="pb-2">7日トレンド</th> <th className="pb-2">契約更新日</th> <th className="pb-2">最終アラート</th> </tr> </thead> <tbody> {sorted.map((tenant) => { const style = ZONE_STYLES[tenant.zone] return ( <tr key={tenant.id} className="border-b hover:bg-gray-50"> <td className="py-3 font-medium"> <a href={`/health/${tenant.id}`} className="text-blue-600 hover:underline"> {tenant.name} </a> </td> <td className="py-3 font-mono text-lg">{tenant.overallScore}</td> <td className="py-3"> <span className={`px-2 py-1 rounded text-xs font-medium ${style.bg} ${style.text}`}> {tenant.zone.toUpperCase()} </span> </td> <td className="py-3"> <TrendIndicator value={tenant.trend7d} /> </td> <td className="py-3 text-sm text-gray-600"> {new Date(tenant.contractEndDate).toLocaleDateString('ja-JP')} </td> <td className="py-3 text-sm text-gray-500"> {tenant.lastAlertAt ? new Date(tenant.lastAlertAt).toLocaleDateString('ja-JP') : '--'} </td> </tr> ) })} </tbody> </table> </div> ) }

個別テナント詳細ビュー

テナントをクリックすると、スコア推移グラフと指標の内訳を表示する。

tsx
// packages/gtm/health-score/src/dashboard/TenantDetail.tsx import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Area, ComposedChart, } from 'recharts' interface ScoreHistory { readonly date: string readonly overallScore: number readonly loginFrequencyScore: number readonly coreFeatureScore: number readonly apiUsageScore: number readonly supportTicketScore: number readonly renewalProximityScore: number readonly movingAvg7d: number | null readonly movingAvg30d: number | null } interface AlertHistory { readonly id: string readonly createdAt: string readonly alertType: string readonly severity: string readonly message: string readonly resolvedAt: string | null } const METRIC_COLORS: Record<string, string> = { overallScore: '#1f2937', loginFrequencyScore: '#3b82f6', coreFeatureScore: '#10b981', apiUsageScore: '#f59e0b', supportTicketScore: '#ef4444', renewalProximityScore: '#8b5cf6', movingAvg7d: '#6b7280', movingAvg30d: '#d1d5db', } export function TenantDetail({ tenantName, history, alerts, }: { readonly tenantName: string readonly history: readonly ScoreHistory[] readonly alerts: readonly AlertHistory[] }) { const latestScore = history[history.length - 1] return ( <div className="space-y-8"> <div> <h2 className="text-2xl font-bold">{tenantName}</h2> <p className="text-gray-500 mt-1">ヘルススコア詳細</p> </div> {/* サマリーカード */} <div className="grid grid-cols-6 gap-4"> {[ { label: '総合', value: latestScore?.overallScore, key: 'overallScore' }, { label: 'ログイン', value: latestScore?.loginFrequencyScore, key: 'loginFrequencyScore' }, { label: 'コア機能', value: latestScore?.coreFeatureScore, key: 'coreFeatureScore' }, { label: 'API', value: latestScore?.apiUsageScore, key: 'apiUsageScore' }, { label: 'サポート', value: latestScore?.supportTicketScore, key: 'supportTicketScore' }, { label: '更新日', value: latestScore?.renewalProximityScore, key: 'renewalProximityScore' }, ].map((metric) => ( <div key={metric.key} className="bg-white rounded-lg border p-4 text-center" > <div className="text-sm text-gray-500">{metric.label}</div> <div className="text-3xl font-bold mt-1" style={{ color: METRIC_COLORS[metric.key] }} > {metric.value ?? '--'} </div> </div> ))} </div> {/* スコア推移グラフ */} <div className="bg-white rounded-lg border p-6"> <h3 className="text-lg font-semibold mb-4">スコア推移(30日間)</h3> <ResponsiveContainer width="100%" height={400}> <ComposedChart data={[...history]}> <CartesianGrid strokeDasharray="3 3" /> <XAxis dataKey="date" tickFormatter={(d: string) => d.slice(5)} fontSize={12} /> <YAxis domain={[0, 100]} fontSize={12} /> <Tooltip /> <ReferenceLine y={70} stroke="#10b981" strokeDasharray="5 5" label="Green" /> <ReferenceLine y={40} stroke="#ef4444" strokeDasharray="5 5" label="Red" /> <Area type="monotone" dataKey="overallScore" fill="#e5e7eb" stroke="none" fillOpacity={0.3} /> <Line type="monotone" dataKey="overallScore" stroke={METRIC_COLORS.overallScore} strokeWidth={2} dot={false} /> <Line type="monotone" dataKey="movingAvg7d" stroke={METRIC_COLORS.movingAvg7d} strokeWidth={1} strokeDasharray="4 4" dot={false} /> <Line type="monotone" dataKey="movingAvg30d" stroke={METRIC_COLORS.movingAvg30d} strokeWidth={1} strokeDasharray="8 4" dot={false} /> </ComposedChart> </ResponsiveContainer> </div> {/* 指標別推移 */} <div className="bg-white rounded-lg border p-6"> <h3 className="text-lg font-semibold mb-4">指標別推移</h3> <ResponsiveContainer width="100%" height={300}> <LineChart data={[...history]}> <CartesianGrid strokeDasharray="3 3" /> <XAxis dataKey="date" tickFormatter={(d: string) => d.slice(5)} fontSize={12} /> <YAxis domain={[0, 100]} fontSize={12} /> <Tooltip /> <Line type="monotone" dataKey="loginFrequencyScore" stroke={METRIC_COLORS.loginFrequencyScore} dot={false} name="ログイン" /> <Line type="monotone" dataKey="coreFeatureScore" stroke={METRIC_COLORS.coreFeatureScore} dot={false} name="コア機能" /> <Line type="monotone" dataKey="apiUsageScore" stroke={METRIC_COLORS.apiUsageScore} dot={false} name="API" /> <Line type="monotone" dataKey="supportTicketScore" stroke={METRIC_COLORS.supportTicketScore} dot={false} name="サポート" /> <Line type="monotone" dataKey="renewalProximityScore" stroke={METRIC_COLORS.renewalProximityScore} dot={false} name="更新日" /> </LineChart> </ResponsiveContainer> </div> {/* アラート履歴 */} <div className="bg-white rounded-lg border p-6"> <h3 className="text-lg font-semibold mb-4">アラート履歴</h3> <div className="space-y-3"> {alerts.map((alert) => ( <div key={alert.id} className={`p-4 rounded-lg border-l-4 ${ alert.severity === 'critical' ? 'border-l-red-500 bg-red-50' : alert.severity === 'warning' ? 'border-l-yellow-500 bg-yellow-50' : 'border-l-blue-500 bg-blue-50' }`} > <div className="flex justify-between items-start"> <div> <span className="text-xs font-medium uppercase text-gray-500"> {alert.alertType} </span> <p className="mt-1 font-medium">{alert.message}</p> </div> <div className="text-right text-sm text-gray-500"> <div>{new Date(alert.createdAt).toLocaleDateString('ja-JP')}</div> {alert.resolvedAt && ( <div className="text-green-600">解決済</div> )} </div> </div> </div> ))} </div> </div> </div> ) }

ダッシュボードの設計ポイントは3つ。

1. 一覧性: テナント一覧で全顧客の状態を一画面で把握できる。ゾーン別フィルタとスコア順ソートで、CSMが「今日注意すべき顧客」をすぐに特定できる。

2. 時系列の可視化: 個別テナントの詳細ビューでは、スコアの絶対値だけでなく推移を見せる。移動平均線を重ねることで、一時的な変動と構造的な低下を区別できる。

3. コンテキスト: アラート履歴を同一画面に表示することで、「過去にどんな問題があったか」「CSMがどう対応したか」の文脈を保つ。

Cloudflare Workers Cron Trigger

バッチ処理をCloudflare Workers Cron Triggerで定期実行する。

typescript
// packages/gtm/health-score/src/worker.ts import { runDailyBatch } from './batch' import { sendSlackAlert } from './notifications/slack' export default { async scheduled( controller: ScheduledController, env: Env, ctx: ExecutionContext, ): Promise<void> { const result = await runDailyBatch() // 実行結果をSlackに通知 if (result.alerts > 0) { await sendSlackAlert({ tenantName: 'システム', score: { overallScore: 0, loginFrequencyScore: 0, coreFeatureScore: 0, apiUsageScore: 0, supportTicketScore: 0, renewalProximityScore: 0, zone: 'red', }, alertMessage: `日次バッチ完了: ${result.processed}テナント処理、${result.alerts}件のアラート発生`, reasoning: null, }) } }, }
toml
# wrangler.toml name = "health-score-batch" main = "src/worker.ts" compatibility_date = "2026-04-01" [triggers] crons = ["0 16 * * *"] # 毎日01:00 JST

効果測定

ヘルススコアシステムの投資対効果を測定するフレームワークを定義する。

チャーン率の変化

最も直接的な効果指標。導入前後でチャーン率がどう変化したかを測る。

指標導入前(想定)導入後目標
月次チャーン率2.5%1.5%
年間チャーン率26.0%16.6%
チャーン顧客の事前検知率30%(肌感覚)80%(スコア+アラート)
チャーン阻止率(検知→対応→継続)20%40%

チャーン率が2.5%から1.5%に改善すると、ARR 1億円の事業で年間1,000万円のMRR維持効果がある。

アラート→CSアクション→解約回避のファネル

システムが出すアラートが実際にチャーン阻止につながっているかを追跡する。

アラート発生(100件/月)
  → CSMがアクション実施(80件: 実施率80%)
    → 顧客との対話実施(60件: 対話率75%)
      → 課題解決(40件: 解決率67%)
        → 契約継続(35件: 継続率88%)

この漏斗の各段階の転換率を追跡し、ボトルネックを特定する。CSMがアクションを実施しないケースが多ければ、アラートのノイズが多すぎる可能性がある。対話はしたが解決に至らないケースが多ければ、プロダクトの課題に踏み込む必要がある。

NRRへの貢献

ヘルススコアシステムはNRRの分母と分子の両方に効く。

Churn削減(分子改善): Red/Yellowゾーンの早期検知で、チャーンを未然に防ぐ。1社の契約を維持するだけで、その顧客のLTV分の価値がある。

Expansion促進(分子改善): Greenゾーンの顧客は、アップセル・クロスセルの提案に対する受容性が高い。ヘルススコアが80以上で安定している顧客を「Expansion候補」としてフラグを立て、営業チームに通知する。

NRR改善の試算(ARR 1億円の場合):

導入前:
  期首MRR: 833万円
  Expansion: +42万円(+5%)
  Contraction: -17万円(-2%)
  Churn: -21万円(-2.5%)
  NRR = (833 + 42 - 17 - 21) / 833 = 100.5%

導入後:
  期首MRR: 833万円
  Expansion: +58万円(+7%)← Greenゾーン顧客への提案強化
  Contraction: -12万円(-1.5%)← 課題の早期解決
  Churn: -12万円(-1.5%)← 予兆検知による阻止
  NRR = (833 + 58 - 12 - 12) / 833 = 104.3%

NRR改善幅: +3.8ポイント
年間MRR増分: 約380万円

NRRが100.5%から104.3%に改善する。3.8ポイントの改善は、ARR 1億円規模では年間約380万円のMRR増分に相当する。ARRが10億円であれば3,800万円だ。

実際に動かしてみて気づいたこと

このシステムを3ヶ月運用して、設計時に想定していなかったことがいくつかあった。

まず、アラートの閾値調整は継続的な作業になる。最初に設定した「スコア40以下でcritical」という基準は、実際にCSMと話してみると「もっと早く気づきたかった」という声があった。結果的に50以下でcriticalに引き上げ、70以下をwarningに設定した。これは業種によっても変わる。

コア機能の定義も難しかった。「全5機能のうち3つ以上使っている」という基準を設けたが、顧客によってそもそも使わない機能がある(業種によって不要な機能が存在する)。この辺りは顧客セグメントごとにコア機能の定義を変えるか、重み付けを調整するかで対応している。

CSMへの通知量のバランスは今でも調整中だ。最初は1日5件以上アラートが来て「多すぎる」という声が出た。今は1日1-2件に絞り、毎朝の確認ルーティンになっている。

次の記事では、このシリーズを締めくくるフレームワーク選定ガイドに入る。Phase 0からPhase 3で使ってきた技術選定の判断軸を一本の記事にまとめる。

$ echo $TAGS
#ヘルススコア#チャーン予兆#カスタマーサクセス#TypeScript#Prisma#SLG