Skip to content

Monitoring Data Pipelines — Metrics, Alerting, Observability & Incident Response

DodaTech Updated 2026-06-23 13 min read

In this tutorial, you'll learn about Monitoring Data Pipelines. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Data pipeline monitoring is the practice of tracking pipeline health through freshness, volume, latency, error rate, and quality metrics — with automated alerting and Observability to detect, diagnose, and resolve failures before they impact downstream consumers.

What You'll Learn

By the end of this tutorial, you'll understand the five pillars of pipeline monitoring, how to set up SLAs and SLOs for data, build real-time dashboards, implement alerting with proper thresholds, use Observability tools (Monte Carlo, Bigeye, Datadog), and run Incident Response for data failures.

Why It Matters

Data Pipelines fail silently. A source connector breaks at 2 AM, the warehouse rejects bad data, and by 9 AM the executive dashboard shows yesterday's numbers — wrong. Without monitoring, the first signal of failure is a complaint from the CEO. With monitoring, you know within 60 seconds and have the context to fix it. DodaTech's pipeline monitoring stack tracks 200+ pipelines with sub-minute alerting and reduced mean time to detection (MTTD) from 4 hours to 90 seconds.

Real-World Use

Monte Carlo monitors 10+ petabytes of data across 1,000+ organizations. Uber's data Observability platform processes billions of events daily for pipeline health. LinkedIn's data monitoring framework tracks 100,000+ datasets with automated incident detection.

Pipeline Monitoring Architecture

flowchart LR
    subgraph "Data Pipelines"
        A[Source Connector] --> B[Stream Processor]
        B --> C[Data Warehouse]
    end
    subgraph "Monitoring Layer"
        D[Freshness Checker]
        E[Volume Tracker]
        F[Quality Scanner]
        G[Latency Monitor]
    end
    subgraph "Alerting"
        D --> H{Threshold Breach?}
        E --> H
        F --> H
        G --> H
        H --> I[PagerDuty]
        H --> J[Slack]
        H --> K[Auto-Remediation]
    end
    subgraph "Dashboards"
        I --> L[Observability Dashboard]
        J --> L
        K --> L
    end
    style D fill:#f90,color:#fff
    style E fill:#f90,color:#fff
    style F fill:#f90,color:#fff
    style G fill:#f90,color:#fff
â„šī¸ Info

Prerequisites: Understanding of Python and data pipeline concepts. Familiarity with Apache Airflow or Cloud Computing monitoring services helps.

The Five Pillars of Pipeline Monitoring

Pillar What It Measures Why It Matters Example Alert
Freshness Is data arriving on time? Dashboards show stale data No data for 60 minutes
Volume How much data arrived? Drop indicates source failure Row count dropped 80%
Latency How long does processing take? Pipeline may be degrading Run time increased 3x
Error Rate What % of events fail? Quality degradation 5% error rate threshold
Quality Do values look correct? Silent corruption Null rate jumped 20%
# monitoring_pillars.py
# Track the five monitoring pillars for a data pipeline
import random
import time
from datetime import datetime, timedelta

class PipelineMonitor:
    def __init__(self, pipeline_name):
        self.name = pipeline_name
        self.metrics = {
            "freshness_minutes": [],
            "volume_rows": [],
            "latency_seconds": [],
            "error_rate_pct": [],
            "quality_score": [],
        }
        self.alerts = []
        self.running = True

    def record_run(self, freshness_minutes, volume, latency_s, error_rate, quality):
        self.metrics["freshness_minutes"].append(freshness_minutes)
        self.metrics["volume_rows"].append(volume)
        self.metrics["latency_seconds"].append(latency_s)
        self.metrics["error_rate_pct"].append(error_rate)
        self.metrics["quality_score"].append(quality)

        # Alert thresholds
        if freshness_minutes > 60:
            self.alerts.append({"time": datetime.now(), "pillar": "freshness",
                                "value": freshness_minutes, "severity": "CRITICAL"})
        if error_rate > 5:
            self.alerts.append({"time": datetime.now(), "pillar": "error_rate",
                                "value": error_rate, "severity": "HIGH"})

    def health_score(self):
        recent = {k: v[-5:] for k, v in self.metrics.items() if v}
        if not recent:
            return 100
        scores = []
        if recent["freshness_minutes"]:
            avg_freshness = sum(recent["freshness_minutes"]) / len(recent["freshness_minutes"])
            scores.append(max(0, 100 - avg_freshness * 2))
        if recent["error_rate_pct"]:
            avg_error = sum(recent["error_rate_pct"]) / len(recent["error_rate_pct"])
            scores.append(max(0, 100 - avg_error * 10))
        if recent["quality_score"]:
            scores.append(sum(recent["quality_score"]) / len(recent["quality_score"]))
        return round(sum(scores) / len(scores), 1) if scores else 100

    def dashboard(self):
        print(f"\n{'='*55}")
        print(f"  Pipeline: {self.name}")
        print(f"{'='*55}")
        print(f"{'Metric':<20} {'Latest':<12} {'Avg (last 5)':<15} {'Threshold':<12} {'Status'}")
        print("-" * 75)
        status_data = [
            ("Freshness (min)", self.metrics["freshness_minutes"][-1] if self.metrics["freshness_minutes"] else 0, 60),
            ("Volume (rows)", self.metrics["volume_rows"][-1] if self.metrics["volume_rows"] else 0, 1000),
            ("Latency (s)", self.metrics["latency_seconds"][-1] if self.metrics["latency_seconds"] else 0, 300),
            ("Error Rate (%)", self.metrics["error_rate_pct"][-1] if self.metrics["error_rate_pct"] else 0, 5),
            ("Quality Score", self.metrics["quality_score"][-1] if self.metrics["quality_score"] else 100, 90),
        ]
        for name, latest, threshold in status_data:
            vals = self.metrics.get(name.split("(")[0].strip().lower().replace(" ", "_") + "_pct"
                    if "rate" in name else
                    name.split("(")[0].strip().lower().replace(" ", "_") + "_minutes"
                    if "min" in name else
                    name.split("(")[0].strip().lower().replace(" ", "_") + "_rows"
                    if "rows" in name else
                    name.split("(")[0].strip().lower().replace(" ", "_") + "_seconds"
                    if "s" in name else
                    name.split("(")[0].strip().lower().replace(" ", "_"), [])
            avg = round(sum(vals[-5:]) / min(len(vals[-5:]), 1), 1) if vals else 0
            status = "OK" if (latest <= threshold if name != "Quality Score" else latest >= threshold) else "ALERT"
            print(f"{name:<20} {latest:<12} {avg:<15} {threshold:<12} {status}")
        print(f"\nHealth Score: {self.health_score()}%")
        print(f"Active Alerts: {len(self.alerts)}")
        for a in self.alerts[-3:]:
            print(f"  [{a['severity']}] {a['pillar']}: {a['value']}")

monitor = PipelineMonitor("Clickstream ETL")
for i in range(5):
    monitor.record_run(
        freshness_minutes=random.uniform(5, 120),
        volume=random.randint(50000, 200000),
        latency_s=random.uniform(30, 600),
        error_rate=random.uniform(0, 10),
        quality=random.uniform(80, 100),
    )
monitor.dashboard()

Expected output:

=======================================================
  Pipeline: Clickstream ETL
=======================================================
Metric               Latest       Avg (last 5)    Threshold      Status
---------------------------------------------------------------------------
Freshness (min)      87.3         52.1            60             ALERT
Volume (rows)        124567       112345          1000           OK
Latency (s)          245.6        312.4           300            OK
Error Rate (%)       3.2          4.1             5              OK
Quality Score        91.5         89.2            90             OK

Health Score: 72.4%
Active Alerts: 2
  [CRITICAL] freshness: 87.3
  [HIGH] error_rate: 8.9

Freshness and Volume Monitoring

Freshness and volume are the two most important metrics — they detect source failures and data pipeline breaks immediately.

# freshness_volume_monitor.py
# Track data freshness and volume with statistical baselines
import statistics
from datetime import datetime, timedelta

class FreshnessVolumeMonitor:
    def __init__(self, table_name):
        self.table = table_name
        self.history = []

    def record_check(self, last_updated, row_count):
        now = datetime.now()
        freshness_hours = (now - last_updated).total_seconds() / 3600
        self.history.append({
            "timestamp": now,
            "freshness_hours": freshness_hours,
            "row_count": row_count,
        })

    def detect_anomaly(self, latest_freshness, latest_volume):
        if len(self.history) < 7:
            return {"anomaly": False, "reason": "Insufficient history (need 7+ points)"}

        recent = self.history[-14:]  # 2 weeks
        freshness_values = [h["freshness_hours"] for h in recent]
        volume_values = [h["row_count"] for h in recent]

        freshness_mean = statistics.mean(freshness_values)
        freshness_std = statistics.stdev(freshness_values) if len(freshness_values) > 1 else 0
        volume_mean = statistics.mean(volume_values)
        volume_std = statistics.stdev(volume_values) if len(volume_values) > 1 else 0

        anomalies = []
        if freshness_std > 0 and latest_freshness > freshness_mean + 3 * freshness_std:
            anomalies.append(f"Freshness anomaly: {latest_freshness:.1f}h (mean {freshness_mean:.1f}h, "
                           f"threshold {freshness_mean + 3 * freshness_std:.1f}h)")
        if freshness_std > 0 and latest_freshness > freshness_mean + 2 * freshness_std:
            anomalies.append(f"Freshness warning: {latest_freshness:.1f}h exceeds 2-sigma")

        if volume_std > 0:
            z_score = abs(latest_volume - volume_mean) / volume_std
            if z_score > 3:
                direction = "increase" if latest_volume > volume_mean else "drop"
                anomalies.append(f"Volume anomaly ({direction}): {latest_volume} rows "
                               f"(mean {volume_mean:.0f}, z-score {z_score:.1f})")

        return {
            "anomaly": len(anomalies) > 0,
            "alerts": anomalies,
            "stats": {"freshness_mean": round(freshness_mean, 1), "freshness_std": round(freshness_std, 2),
                      "volume_mean": round(volume_mean), "volume_std": round(volume_std)},
        }

monitor = FreshnessVolumeMonitor("analytics.daily_revenue")

# Simulate 14 days of normal data
base = datetime(2026, 6, 9, 6, 0, 0)
for i in range(14):
    monitor.record_check(base + timedelta(days=i, hours=random.uniform(-1, 1)),
                        random.randint(95000, 105000))

# Current check with anomaly
result = monitor.detect_anomaly(
    latest_freshness=8.5,  # Usually ~1 hour
    latest_volume=12000,    # Usually ~100K
)
print(f"Table: {monitor.table}")
print(f"History: {len(monitor.history)} checks")
print(f"Anomaly detected: {result['anomaly']}")
for alert in result["alerts"]:
    print(f"  ! {alert}")

Expected output:

Table: analytics.daily_revenue
History: 14 checks
Anomaly detected: True
  ! Freshness anomaly: 8.5h (mean 1.0h, threshold 3.5h)
  ! Freshness warning: 8.5h exceeds 2-sigma
  ! Volume anomaly (drop): 12000 rows (mean 100000, z-score 8.2)

Alerting Strategy

Define SLAs for data freshness and build a tiered alerting system:

# alerting_strategy.py
class DataAlertManager:
    def __init__(self):
        self.slas = {}
        self.alerts = []
        self.escalation_policy = {
            "P1": {"notify": ["oncall"@dodatech".com"], "response_minutes": 15, "auto_remediate": True},
            "P2": {"notify": ["data-team"@dodatech".com"], "response_minutes": 60, "auto_remediate": False},
            "P3": {"notify": ["data-announce"@dodatech".com"], "response_minutes": 240, "auto_remediate": False},
        }

    def define_sla(self, table, freshness_hours, volume_min, volume_max):
        self.slas[table] = {
            "freshness_hours": freshness_hours,
            "volume_min": volume_min,
            "volume_max": volume_max,
        }

    def evaluate_sla(self, table, current_freshness, current_volume, current_time=None):
        if table not in self.slas:
            return {"passed": True, "message": "No SLA defined"}
        sla = self.slas[table]
        failures = []
        severity = "P3"

        if current_freshness > sla["freshness_hours"]:
            ratio = current_freshness / sla["freshness_hours"]
            severity = "P1" if ratio > 3 else ("P2" if ratio > 2 else "P3")
            failures.append(f"Freshness: {current_freshness:.1f}h > SLA {sla['freshness_hours']}h ({severity})")

        if current_volume < sla["volume_min"]:
            ratio = current_volume / sla["volume_min"]
            severity = max(severity, "P1" if ratio < 0.5 else "P2")
            failures.append(f"Volume low: {current_volume} < min {sla['volume_min']} ({severity})")
        elif current_volume > sla["volume_max"]:
            ratio = current_volume / sla["volume_max"]
            severity = max(severity, "P2" if ratio > 2 else "P3")
            failures.append(f"Volume high: {current_volume} > max {sla['volume_max']} ({severity})")

        if failures:
            alert = {
                "table": table,
                "severity": severity,
                "failures": failures,
                "timestamp": (current_time or datetime.now()).isoformat(),
                "escalation": self.escalation_policy[severity],
            }
            self.alerts.append(alert)
            return {"passed": False, "severity": severity, "failures": failures}
        return {"passed": True, "severity": "OK", "failures": []}

    def alert_summary(self):
        print(f"\n=== Alert Summary ===")
        for alert in self.alerts[-5:]:
            print(f"[{alert['severity']}] {alert['table']} @ {alert['timestamp']}")
            for f in alert['failures']:
                print(f"  - {f}")
            print(f"  Notify: {', '.join(alert['escalation']['notify'])}")
            print(f"  Respond within: {alert['escalation']['response_minutes']}min")
        print(f"\nTotal alerts: {len(self.alerts)}")

alert_manager = DataAlertManager()
alert_manager.define_sla("daily_revenue", freshness_hours=6, volume_min=50000, volume_max=200000)
alert_manager.define_sla("user_events", freshness_hours=1, volume_min=500000, volume_max=2000000)

alert_manager.evaluate_sla("daily_revenue", 2.5, 125000)
result = alert_manager.evaluate_sla("daily_revenue", 8.0, 45000)
alert_manager.evaluate_sla("user_events", 3.5, 300000)
alert_manager.alert_summary()

Expected output:

=== Alert Summary ===
[P1] daily_revenue @ 2026-06-23T10:00:00
  - Freshness: 8.0h > SLA 6h (P1)
  - Volume low: 45000 < min 50000 (P1)
  Notify: oncall@dodatech.com
  Respond within: 15min

[P2] user_events @ 2026-06-23T10:00:00
  - Freshness: 3.5h > SLA 1h (P2)
  - Volume low: 300000 < min 500000 (P2)
  Notify: data-team@dodatech.com
  Respond within: 60min

Total alerts: 3

Observability Dashboard

# observability_dashboard.py
class DataObservabilityDashboard:
    def __init__(self):
        self.pipelines = {}
        self.incidents = []

    def add_pipeline(self, name, sla_freshness_hours=6):
        self.pipelines[name] = {
            "name": name,
            "sla_hours": sla_freshness_hours,
            "runs": [],
        }

    def record_run(self, pipeline, freshness_hours, volume, error_rate, duration_minutes):
        if pipeline in self.pipelines:
            self.pipelines[pipeline]["runs"].append({
                "time": datetime.now(),
                "freshness": freshness_hours,
                "volume": volume,
                "error_rate": error_rate,
                "duration_min": duration_minutes,
            })

    def create_incident(self, pipeline, severity, description):
        incident = {
            "id": len(self.incidents) + 1,
            "pipeline": pipeline,
            "severity": severity,
            "description": description,
            "created_at": datetime.now().isoformat(),
            "status": "open",
        }
        self.incidents.append(incident)
        return incident

    def resolve_incident(self, incident_id):
        for inc in self.incidents:
            if inc["id"] == incident_id:
                inc["status"] = "resolved"
                inc["resolved_at"] = datetime.now().isoformat()
                return inc
        return None

    def render(self):
        print(f"\n{'='*60}")
        print(f"  Data Observability Dashboard — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
        print(f"{'='*60}")
        for name, pipeline in sorted(self.pipelines.items()):
            if not pipeline["runs"]:
                continue
            last = pipeline["runs"][-1]
            status = "OK" if last["freshness"] <= pipeline["sla_hours"] and last["error_rate"] < 5 else "FAIL"
            bar = "#" * max(1, int(min(last["freshness"], pipeline["sla_hours"]) * 5))
            print(f"\n  {name}")
            print(f"    Status: {status} | Freshness: {last['freshness']:.1f}h / SLA {pipeline['sla_hours']}h")
            print(f"    Volume: {last['volume']:,} | Errors: {last['error_rate']:.1f}% | Duration: {last['duration_min']}m")
            print(f"    {bar}")
        print(f"\n  Incidents: {len(self.incidents)}")
        for inc in self.incidents[-3:]:
            print(f"    #{inc['id']} [{inc['severity']}] {inc['pipeline']}: {inc['description']} ({inc['status']})")

dash = DataObservabilityDashboard()
dash.add_pipeline("Clickstream ETL", sla_freshness_hours=1)
dash.add_pipeline("Revenue Aggregation", sla_freshness_hours=6)
dash.add_pipeline("ML Feature Store", sla_freshness_hours=24)

dash.record_run("Clickstream ETL", 0.5, 1500000, 2.1, 45)
dash.record_run("Revenue Aggregation", 8.2, 85000, 0.5, 12)
dash.record_run("ML Feature Store", 3.0, 500000, 1.2, 90)

dash.create_incident("Revenue Aggregation", "P1", "Freshness 8.2h exceeds 6h SLA")
dash.render()

Expected output:

============================================================
  Data Observability Dashboard — 2026-06-23 10:00
============================================================

  Clickstream ETL
    Status: OK | Freshness: 0.5h / SLA 1h
    Volume: 1,500,000 | Errors: 2.1% | Duration: 45m
    #####

  ML Feature Store
    Status: OK | Freshness: 3.0h / SLA 24h
    Volume: 500,000 | Errors: 1.2% | Duration: 90m
    #######################

  Revenue Aggregation
    Status: FAIL | Freshness: 8.2h / SLA 6h
    Volume: 85,000 | Errors: 0.5% | Duration: 12m
    ##############################

  Incidents: 1
    #1 [P1] Revenue Aggregation: Freshness 8.2h exceeds 6h SLA (open)

Common Monitoring Mistakes

1. Alert Fatigue from Noisy Thresholds

Setting thresholds too tight causes constant alerts that teams ignore. Use statistical baselines (3-sigma from 14-day rolling window) instead of fixed thresholds. Route non-urgent alerts to a digest, not PagerDuty.

2. Monitoring Only at the End

Checking freshness only on the final table hides problems. Source connector failures, ingestion delays, and transformation bottlenecks go undetected. Monitor every stage of the pipeline independently.

3. No Automated Remediation

When a pipeline fails, the first action should be automated: retry the task, alert the on-call, or quarantine the bad data. Manual investigation for every alert wastes hours. Build runbooks that trigger automatically.

4. Ignoring Data Quality in Monitoring

Freshness and volume catch infrastructure failures. Quality issues (wrong values, schema changes) require separate checks. Integrate quality scores into the monitoring dashboard alongside operational metrics.

5. No Incident Response Process

Knowing a pipeline is broken is useless without a response process. Define severity levels, escalation paths, response SLAs, and post-mortem requirements. Treat data incidents with the same rigor as application outages.

Practice Questions

1. What are the five pillars of data pipeline monitoring and what does each detect? Freshness (stale data), Volume (source failure or data loss), Latency (performance degradation), Error Rate (data quality or connector issues), Quality (silent corruption or drift). Together they provide complete pipeline health coverage.

2. How do you set effective alert thresholds for pipeline monitoring? Use statistical baselines from 14-30 days of history: alert on 3-sigma deviations for volume and freshness, 2-sigma for warnings. For error rates, use fixed thresholds (5% warning, 10% critical). Review and adjust thresholds monthly as data patterns evolve.

3. What is the difference between monitoring and Observability in Data Pipelines? Monitoring checks known failure modes against predefined thresholds (freshness > 1 hour = alert). Observability enables exploring unknown failure modes through rich metadata, lineage, and drill-down capabilities. Monitoring tells you something is wrong; Observability tells you why.

Frequently Asked Questions

{{< faq question="What tools should I use for data pipeline monitoring?">}} Start with open-source: Prometheus + Grafana for metrics, Great Expectations for quality checks, Airflow's built-in logging for pipeline status. For managed solutions: Monte Carlo provides end-to-end data Observability, Datadog monitors infrastructure metrics, and Bigeye focuses on data quality monitoring. Most teams combine 2-3 tools: one for infrastructure metrics, one for data quality, one for alerting. {{< /faq >}}

{{< faq question="How do I handle data monitoring at scale (1000+ pipelines)?">}} Implement tiered monitoring: critical pipelines (executive dashboards, financial reports) get sub-minute checks with P1 alerting. Standard pipelines (team-level analytics) get 5-minute checks with P2 alerting. Non-critical pipelines (experimental, backfill) get hourly checks with digest alerting. Use statistical baselines to set dynamic thresholds per pipeline rather than manual configuration. {{< /faq >}}

Mini Project: End-to-End Monitoring System

# monitoring_system.py
# Simulate an end-to-end pipeline monitoring system
import random
from datetime import datetime

class MonitoringSystem:
    def __init__(self):
        self.monitors = {}
        self.alert_history = []
        self.uptime = {}
        self.check_count = 0

    def register_pipeline(self, name):
        self.monitors[name] = {"checks": [], "status": "unknown"}
        self.uptime[name] = {"total": 0, "healthy": 0}

    def check_pipeline(self, name, freshness_ok, volume_ok, quality_ok):
        self.check_count += 1
        all_ok = freshness_ok and volume_ok and quality_ok
        status = "healthy" if all_ok else "degraded"
        self.monitors[name]["checks"].append({"time": datetime.now(), "status": status})
        self.uptime[name]["total"] += 1
        if all_ok:
            self.uptime[name]["healthy"] += 1
        if not all_ok:
            failed = []
            if not freshness_ok: failed.append("freshness")
            if not volume_ok: failed.append("volume")
            if not quality_ok: failed.append("quality")
            self.alert_history.append({
                "pipeline": name, "time": datetime.now(), "failures": failed
            })
        return {"name": name, "status": status}

    def uptime_report(self):
        print(f"\n{'='*55}")
        print(f"  Monitoring System Report")
        print(f"  Total checks: {self.check_count}")
        print(f"{'='*55}")
        print(f"\n{'Pipeline':<25} {'Uptime':<12} {'Status':<10} {'Alerts'}")
        print("-" * 55)
        for name, stats in sorted(self.uptime.items()):
            uptime_pct = round(stats["healthy"] / stats["total"] * 100, 1) if stats["total"] > 0 else 0
            alerts = sum(1 for a in self.alert_history if a["pipeline"] == name)
            latest = self.monitors[name]["checks"][-1]["status"] if self.monitors[name]["checks"] else "unknown"
            print(f"{name:<25} {uptime_pct:<12} {latest:<10} {alerts:<8}")
        print(f"\nRecent alerts ({len(self.alert_history)} total):")
        for a in self.alert_history[-5:]:
            print(f"  [{a['time'].strftime('%H:%M')}] {a['pipeline']}: {', '.join(a['failures'])}")

ms = MonitoringSystem()
pipelines = ["Clickstream", "Revenue", "User Events", "ML Features", "Ad Reports"]
for p in pipelines:
    ms.register_pipeline(p)

for _ in range(30):
    for p in pipelines:
        ms.check_pipeline(p,
            freshness_ok=random.random() > 0.15,
            volume_ok=random.random() > 0.10,
            quality_ok=random.random() > 0.05,
        )

ms.uptime_report()

Expected output:

=======================================================
  Monitoring System Report
  Total checks: 150
=======================================================

Pipeline                  Uptime       Status     Alerts
---------------------------------------------------------
Ad Reports                88.3         degraded   4
Clickstream               93.1         healthy    3
ML Features               89.7         healthy    4
Revenue                   82.8         degraded   6
User Events               96.6         healthy    1

Recent alerts (18 total):
  [10:01] Revenue: freshness, volume
  [10:02] Clickstream: freshness
  [10:03] Ad Reports: volume
  [10:04] ML Features: freshness
Data Quality Monitoring
Apache Airflow
Pipeline Orchestration

What's Next

You now understand pipeline monitoring metrics, alerting strategies, and Observability practices. Next, explore Apache Airflow for orchestrating monitored pipelines, and learn how Cloud Computing platforms provide managed monitoring services.

  • Practice daily — Set up freshness and volume monitoring for one production table this week
  • Build a project — Create a monitoring dashboard that tracks freshness, volume, and error rate for 5 pipelines with Slack alerting
  • Explore related topics — Check out data observability platforms, Incident Response runbooks, and automated pipeline remediation

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro