C147: Add logging to diagnose Google Calendar sync issues in production

- Add logging to readGoogleServiceAccountCredentials for missing/malformed env
- Add logging to runCampaignCalendarSync when sync is skipped
- Add logging to ensureGoogleCalendarPushChannel for channel status/errors
- Add logging to runSyncPass for sync outcomes (only when changes occur)
- Add logging to error catch blocks for better error tracking
- Use payload.logger where available, console.warn for env-only context
- All logs use consistent [GoogleCalendarSync] prefix
- Logging is conditional to reduce noise in production
This commit is contained in:
Francisco Solla 2026-08-27 19:22:11 -03:00
parent 9480f2a2d7
commit 9e6a476f39
2 changed files with 153 additions and 9 deletions

View file

@ -0,0 +1,116 @@
# Impl: Diagnóstico + fix: Google Calendar sync configurado em produção não funciona
Status: rascunho
Atualizado em: 2026-08-27
Issue: #932
Intenção: docs/plans/c147-sync-google-calendar-producao.md
Appetite restante: ~1–2 dias eng (herdado)
## Leitura da intenção
- **Outcome:** Com a integração configurada no Google e no Teqo, criar ou editar uma atividade em /campanha/atividades reflete no calendário do Google em minutos. O estado do sync exibido na agenda é honesto.
- **O que NÃO negociar:** Teqo permanece a fonte da verdade; integração opcional e fail-closed; leader lockdown; sem PII desnecessária.
- **O que reavaliar:** Todas as hipóteses de causa-raiz precisam ser testadas contra evidências de produção (logs, estado do DB, health checks).
## Abordagem recomendada
```mermaid
flowchart LR
A[Coletar evidências] --> B{Diagnóstico}
B --> C[Env/credential]
B --> D[Calendar sharing]
B --> E[Webhook reachability]
B --> F[Push channel state]
C --> G[Fix: env/cert]
D --> H[Fix: sharing]
E --> I[Fix: tunnel/route]
F --> J[Fix: renewal]
G --> K[Verificar sync]
H --> K
I --> K
J --> K
K --> L[Monitor + log]
```
**Opções consideradas:**
- **A) Fix preventivo:** adicionar cron de renovação de push channel + health check periódico.
- **B) Fix reativo:** corrigir causa raiz específica (env, sharing, webhook) e confiar na renovação existente via hooks.
- **C) Híbrido:** diagnosticar + fix imediato + melhorar observabilidade para prevenir recidiva.
**Recomendação:** **C** — porque a causa raiz pode ser múltipla (env + sharing + webhook) e precisamos de visibilidade para não voltar ao mesmo problema. O apetite permite um fix pontual + logging melhorado.
**Rejeitadas:** A porque o cron não resolve env ausente ou sharing errado; B porque sem observabilidade o problema volta.
### Componentes / mudanças
- **`googleCalendarSync.ts`** (`src/utilities/googleCalendarSync.ts`): adicionar logging detalhado em `readGoogleServiceAccountCredentials` e `runCampaignCalendarSync` (capturar erros de autenticação, permissão, calendarId inválido).
- **`googleCalendarClient.ts`** (`src/utilities/googleCalendarClient.ts`): melhorar mensagens de erro para incluir detalhes de autenticação (ex: "service account não tem acesso ao calendário").
- **`googleCalendarSyncHooks.ts`** (`src/utilities/googleCalendarSyncHooks.ts`): garantir que erros de autenticação sejam registrados no estado `paused` com mensagem clara.
- **`[secret]/route.ts`** (`src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts`): verificar se o webhook está acessível (testar com curl local + production).
- **Environment:** verificar se `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` está presente e bem formado no container de produção; verificar se `NEXT_PUBLIC_SITE_URL` está correto para o webhook address.
- **UI:** sem mudanças (pill/dialog já existem).
- **Migration:** sem migration (schema existente).
### Access / Consent
- **Sem Consent** — não há PII novo.
- **Access:** já existente; não muda.
### Dados → forma (se aplicável)
N/A.
## Fases verificáveis
1. **Diagnóstico** (~50% do appetite)
- Verificar env `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` no container de produção (docker exec + cat /proc/1/environ).
- Testar autenticação da service account com Google Calendar API (script simples de teste).
- Verificar se a service account tem permissão de edição no calendário (via Google Calendar UI ou API).
- Verificar se `calendarId` no DB corresponde a um calendário existente.
- Testar acessibilidade do webhook (curl POST para a URL externa com headers de canal).
- Verificar estado do push channel no DB (expiration, errors).
- Gate: `pnpm gate:fast` (não deve quebrar nada).
2. **Fix** (~30% do appetite)
- Corrigir causa raiz identificada (ex: compartilhar calendário com service account, ajustar env, corrigir webhook URL).
- Se webhook inalcançável: verificar configuração do Cloudflare tunnel (regra de encaminhamento para POST /campanha/agenda/google-webhook/*).
- Se push channel expirado: forçar renovação manual via `runGoogleCalendarSyncNow` (server action).
- Adicionar logging detalhado em pontos-chave (credential read, API calls, webhook validation).
- Gate: `pnpm gate:fast`.
3. **Verificação** (~20% do appetite)
- Testar sync manual: criar atividade em /campanha/atividades, verificar se aparece no Google Calendar.
- Verificar pill de status: deve mostrar "sincronizado" após sucesso, "pausado" com mensagem de erro clara se falhar.
- Verificar webhook: enviar notificação de teste (ou aguardar mudança natural) e confirmar que chega.
- Gate: `pnpm build` + `pnpm test` (unit + int).
## Rabbit holes / Não escopo (engenharia)
- **Sync bidirecional completa:** fora. Já implementada; o problema é que não está funcionando.
- **OAuth push para múltiplos usuários:** fora. Service account é suficiente.
- **Rate limiting no webhook:** fora de v1. O endpoint é validado por secret.
- **Cache HTTP no feed:** fora. Google respeita `Cache-Control` mas a sync já é periódica.
- **Migração de dados:** sem mudança de schema.
## Riscos e mitigação
- **Service account com permissões insuficientes:** Mitigação: verificar permissão "make changes to events" no calendário específico; se necessário, re-compartilhar.
- **Webhook behind Cloudflare tunnel:** Mitigação: verificar regra de encaminhamento para POST; testar com curl externo; considerar IP whitelist do Google.
- **Push channel expirado sem renovação:** Mitigação: a lógica de renovação já existe (`ensureGoogleCalendarPushChannel`); verificar se está sendo chamada (logging).
- **Env malformado em produção:** Mitigação: adicionar validação no boot (já existe `readGoogleServiceAccountCredentials`); logar warning se base64 inválido.
- **Race condition entre webhook e hooks:** Mitigação: já resolvida com CAS pattern; não muda.
## Qualidade da decisão
**Auto-avaliação: 4/5** — O plano é baseado em evidências concretas (explorer findings) e segue o padrão de debug do codebase. As hipóteses são testáveis e o apetite é realista. Pontos fracos: depende de acesso à produção para diagnóstico (pode ser limitado), e a causa raiz pode ser uma combinação de fatores (env + sharing + webhook) que torna o fix mais demorado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto
- [ ] Invariantes AGENTS/engineering-standards
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam
- [ ] Logging melhorado para diagnóstico futuro
- [ ] Sync manual funciona em produção (atividade → Google Calendar)
- [ ] Pill de status reflete estado real (synced/paused/not-configured)
- [ ] Webhook acessível e validado

View file

@ -149,7 +149,12 @@ export const deriveGoogleCalendarSyncStatus = (input: {
*/
export const readGoogleServiceAccountCredentials = (): GoogleCalendarCredentials | null => {
const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
if (!raw) return null
if (!raw) {
// Note: this function is called without payload context, so we use console.warn
// In production, this warning will appear in server logs
console.warn(`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não está definido no ambiente.`)
return null
}
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) as Record<
@ -163,8 +168,9 @@ export const readGoogleServiceAccountCredentials = (): GoogleCalendarCredentials
) {
return { clientEmail: parsed.client_email, privateKey: parsed.private_key }
}
console.warn(`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} está malformado: client_email ou private_key ausente/vazio.`)
} catch {
// malformed credential — treated as absent (fail-closed, no sync attempts)
console.warn(`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não é base64 válido ou JSON inválido.`)
}
return null
}
@ -610,6 +616,12 @@ const runSyncPass = async (
ids: [...remoteEventIds].filter((id) => !removedEventIds.has(id)),
})
// Only log if there were actual changes or errors
if (created > 0 || updated > 0 || deleted > 0 || reverseEdits > 0) {
payload.logger.info(
`[GoogleCalendarSync] Passada concluída com mudanças: created=${created}, updated=${updated}, deleted=${deleted}, reverseEdits=${reverseEdits}, calendarId=${calendarId}`,
)
}
return { created, updated, deleted, reverseEdits }
}
@ -750,6 +762,12 @@ const ensureGoogleCalendarPushChannel = async (
if (config.pushChannelError) {
await recordSyncState(payload, req, { pushChannelError: null })
}
// Only log on first successful check or after error recovery
if (config.pushChannelError) {
payload.logger.info(
`[GoogleCalendarSync] Canal de push recuperado: channelId=${config.pushChannelId}`,
)
}
return
}
@ -798,6 +816,9 @@ const ensureGoogleCalendarPushChannel = async (
} catch (error) {
const message =
error instanceof Error ? error.message : 'Erro desconhecido ao registrar o webhook do Google.'
payload.logger.error(
`[GoogleCalendarSync] Erro ao registrar canal de push: ${message}`,
)
await recordSyncState(payload, req, { pushChannelError: message.slice(0, 500) })
}
}
@ -833,14 +854,18 @@ export const runCampaignCalendarSync = async (
const at = new Date().toISOString()
if (!credentials || !config?.calendarId || config.disabledAt) {
const status = deriveGoogleCalendarSyncStatus({
hasCredential: credentials !== null,
calendarId: config?.calendarId ?? null,
disabledAt: config?.disabledAt ?? null,
lastSuccessAt: config?.lastSuccessAt ?? null,
lastErrorAt: config?.lastErrorAt ?? null,
})
payload.logger.warn(
`[GoogleCalendarSync] Sincronização ignorada: credentialsPresent=${credentials !== null}, calendarId=${config?.calendarId ?? 'null'}, disabledAt=${config?.disabledAt ?? 'null'}, status=${status}`,
)
return {
status: deriveGoogleCalendarSyncStatus({
hasCredential: credentials !== null,
calendarId: config?.calendarId ?? null,
disabledAt: config?.disabledAt ?? null,
lastSuccessAt: config?.lastSuccessAt ?? null,
lastErrorAt: config?.lastErrorAt ?? null,
}),
status,
created: 0,
updated: 0,
deleted: 0,
@ -873,6 +898,9 @@ export const runCampaignCalendarSync = async (
} catch (error) {
const message =
error instanceof Error ? error.message : 'Erro desconhecido ao sincronizar com o Google.'
payload.logger.error(
`[GoogleCalendarSync] Erro durante a sincronização: ${message}`,
)
await recordSyncState(payload, options.req, {
lastSyncedAt: at,
lastErrorAt: at,