um(['in-app', 'support', 'review', 'community', 'api']),
category: z.enum(['bug', 'feature-request', 'usability', 'billing', 'other']),
severity: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
content: z.string().min(1).max(2000),
metadata: z.object({
userAgent: z.string().optional(),
locale: z.string().optional(),
appVersion: z.string().optional(),
pageUrl: z.string().optional(),
screenshotBase64: z.string().optional()
}).optional(),
timestamp: z.string().datetime()
});
export type FeedbackPayload = z.infer<typeof FeedbackSchema>;
### Step 2: Ingestion API
The ingestion endpoint must be idempotent, rate-limited, and schema-validated. It publishes to a message broker and returns immediately.
```typescript
// api/ingest.ts
import { FastifyInstance } from 'fastify';
import { FeedbackSchema } from '../schemas/feedback';
import { publishToQueue } from '../infra/queue';
import { redactPII } from '../utils/pii-redaction';
export async function registerIngestionRoute(app: FastifyInstance) {
app.post<{ Body: FeedbackPayload }>('/v1/feedback', {
schema: { body: FeedbackSchema },
preHandler: [app.rateLimiter],
handler: async (request, reply) => {
const { idempotencyKey, content, ...rest } = request.body;
// Deduplicate at ingestion
const exists = await app.db.redis.get(`feedback:idem:${idempotencyKey}`);
if (exists) {
return reply.code(200).send({ status: 'duplicate', idempotencyKey });
}
const sanitizedContent = redactPII(content);
const event = { idempotencyKey, content: sanitizedContent, ...rest };
await Promise.all([
publishToQueue('feedback.ingested', event),
app.db.redis.set(`feedback:idem:${idempotencyKey}`, '1', { EX: 86400 })
]);
return reply.code(202).send({ status: 'accepted', idempotencyKey });
}
});
}
Step 3: Event Processing & Enrichment
Consumers must classify intent, calculate a feedback score, and attach routing metadata. This runs asynchronously to avoid blocking ingestion.
// processors/classifier.ts
import { consumeFromQueue } from '../infra/queue';
import { db } from '../infra/db';
import { routeToTicketing } from '../routing/ticketing';
import { notifyUser } from '../notifications/feedback';
consumeFromQueue('feedback.ingested', async (event) => {
// Lightweight intent classification (replace with ML service in production)
const intent = classifyIntent(event.content);
const feedbackScore = calculateScore(event, intent);
const record = await db.feedback.create({
data: {
idempotencyKey: event.idempotencyKey,
userId: event.userId,
channel: event.channel,
category: event.category,
severity: event.severity,
intent,
feedbackScore,
status: 'triaged',
processedAt: new Date()
}
});
// Route based on score and category
if (feedbackScore >= 75 || event.severity === 'critical') {
await routeToTicketing(record, { priority: 'high' });
} else if (event.category === 'feature-request') {
await routeToTicketing(record, { priority: 'medium', label: 'backlog' });
}
// Close the loop: notify user if feedback triggered action
if (event.userId) {
await notifyUser(event.userId, {
type: 'feedback-acknowledged',
payload: { feedbackId: record.id, nextSteps: 'Under review by engineering' }
});
}
});
function classifyIntent(text: string): string {
const keywords = {
'bug': ['crash', 'error', 'broken', 'not working', 'exception'],
'feature-request': ['add', 'could we', 'wish', 'suggest', 'implement'],
'usability': ['confusing', 'hard to', 'slow', 'layout', 'navigation']
};
const lower = text.toLowerCase();
for (const [intent, terms] of Object.entries(keywords)) {
if (terms.some(t => lower.includes(t))) return intent;
}
return 'general';
}
function calculateScore(event: any, intent: string): number {
let score = 50;
if (event.severity === 'critical') score += 30;
if (event.severity === 'high') score += 15;
if (event.channel === 'support') score += 10;
if (intent === 'bug') score += 15;
if (event.metadata?.appVersion?.includes('latest')) score += 5;
return Math.min(score, 100);
}
Step 4: Architecture Decisions & Rationale
Event-Driven vs. Synchronous: Feedback ingestion is decoupled from processing. Synchronous triage blocks the API under load and increases latency. Event sourcing via Kafka or RabbitMQ enables replay, scaling, and fault tolerance. Failed processors can retry without data loss.
Idempotency at the Edge: Duplicate feedback submissions are common. Retries, network blips, and user double-clicks generate identical payloads. Storing idempotency keys in Redis with a 24-hour TTL prevents duplicate tickets and queue bloat.
Schema Versioning: Feedback requirements change. The schema must support versioning (/v1/feedback, /v2/feedback) and backward-compatible field additions. Breaking changes require migration scripts and consumer version negotiation.
PII Redaction: Free-text feedback contains emails, phone numbers, and internal references. A lightweight regex-based redaction layer runs before storage and queue publication. This satisfies GDPR/CCPA requirements without blocking ingestion.
Feedback Scoring Matrix: Not all feedback warrants equal attention. The scoring algorithm weights severity, channel reliability, intent classification, and app version. Scores above 75 trigger high-priority routing. Scores below 50 enter the backlog queue. This prevents vocal minorities from hijacking sprint capacity.
Closed-Loop Notification: Users who receive acknowledgment within 2 hours submit feedback 3.2x more frequently. The system triggers a lightweight notification (in-app toast, email, or push) when feedback crosses the routing threshold. This builds trust and increases signal volume over time.
Pitfall Guide
1. Collecting Without Closing the Loop
Submitting feedback into a black hole destroys user trust. If the system never acknowledges receipt or communicates status, repeat submission rates collapse. Always implement a minimum acknowledgment workflow, even if the feedback is deprioritized.
2. Treating All Feedback Equally
A single critical bug report from a power user outweighs fifty vague feature requests. Without a scoring matrix, engineering triage becomes subjective. Implement weighted routing based on severity, user tier, frequency, and intent classification.
3. Over-Indexing on Sentiment Scores
NLP sentiment analysis measures emotion, not intent. A user can express frustration ("this is unusable") while reporting a minor UI misalignment. Conversely, neutral language can mask critical data loss. Route by intent and severity, not sentiment polarity.
4. Skipping Schema Versioning
Adding a new field to the feedback payload breaks consumers that expect strict shapes. Without versioning, a single schema change can halt ticket creation, notification dispatch, or analytics pipelines. Version endpoints, maintain migration contracts, and use backward-compatible extensions.
5. Missing Idempotency Controls
Retry storms from mobile networks or client SDKs generate duplicate events. Without idempotency keys, your queue processes the same feedback multiple times, creating duplicate Jira/Linear tickets and inflating metrics. Enforce idempotency at ingestion and store keys with TTLs.
6. No Feedback Decay Model
Stale feedback blocks roadmap planning. A bug reported in v2.1 may be resolved in v2.3, but the ticket remains open. Implement automatic decay: mark feedback as resolved if the associated app version ships, or close tickets after 90 days of inactivity with a summary comment.
7. Ignoring Privacy & Compliance Boundaries
Free-text fields capture PII by default. Storing emails, names, or internal IDs in feedback databases violates GDPR, CCPA, and SOC2 requirements. Run PII redaction before persistence, mask sensitive fields in analytics, and provide user data export/deletion hooks.
Production Best Practices:
- Route feedback through a single ingestion API, even if collected via multiple SDKs
- Store raw content separately from processed metadata for auditability
- Monitor queue depth, processor lag, and closure rate as primary SLOs
- A/B test notification timing to maximize user engagement without fatigue
- Log routing decisions with deterministic rules for post-mortem analysis
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP | Synchronous API + SQLite + Linear Webhooks | Fastest path to closed loop, minimal infra overhead | Low ($50β150/mo) |
| Mid-Market Scaling | Event-driven (Kafka/RabbitMQ) + PostgreSQL + ML Classifier | Handles volume, enables replay, supports multi-team routing | Medium ($300β800/mo) |
| Enterprise Compliance | Event-driven + PII Vault + Schema Registry + SOC2 Audit Trail | Enforces data contracts, satisfies audit requirements, isolates sensitive content | High ($1.2kβ3k/mo) |
Configuration Template
# feedback-loop.config.yaml
ingestion:
rate_limit: 100 req/min
max_payload_size: 4096
idempotency_ttl_hours: 24
schema:
version: v1
strict_mode: true
allow_unknown_fields: false
processing:
queue: feedback.ingested
concurrency: 5
retry_policy:
max_attempts: 3
backoff_ms: 1000
jitter: true
routing:
score_thresholds:
critical: 90
high: 75
medium: 50
low: 20
ticketing:
provider: linear
webhook_url: ${LINEAR_WEBHOOK_URL}
default_labels: ["feedback", "triaged"]
notifications:
provider: sendgrid
template_id: d_feedback_ack
delay_minutes: 5
max_per_user_per_day: 3
observability:
metrics:
- feedback_ingested_total
- feedback_triaged_total
- feedback_closed_loop_total
- queue_lag_seconds
alerts:
- metric: queue_lag_seconds
threshold: 300
severity: warning
Quick Start Guide
- Initialize the project:
npm init -y && npm install fastify zod @sendgrid/mail ioredis @linear/sdk
- Create the schema & ingestion route: Copy the
schemas/feedback.ts and api/ingest.ts examples into your project. Configure Fastify with Zod validation and Redis for idempotency.
- Deploy the processor: Implement the async consumer from
processors/classifier.ts. Connect to your message broker (RabbitMQ, Kafka, or BullMQ for local dev). Add Linear/Jira webhook integration for ticket creation.
- Run locally:
docker run -d -p 6379:6379 redis:7-alpine then node server.js. Test with curl -X POST http://localhost:3000/v1/feedback -H "Content-Type: application/json" -d '{"idempotencyKey":"550e8400-e29b-41d4-a716-446655440000","userId":"u_123","channel":"in-app","category":"bug","severity":"high","content":"App crashes on checkout","timestamp":"2024-01-15T10:00:00Z"}'. Verify queue consumption, ticket creation, and acknowledgment notification within 60 seconds.