· 9 min read

ChartImpact: Beyond the Diff — Persistence, Analytics, and Team Workflows


In my previous post, I introduced ChartImpact and its semantic diff engine that understands Kubernetes resources. The tool answered a critical question: “what’s actually changing in this Helm chart upgrade?” But in practice, teams needed more than just one-off comparisons.

The Collaboration Problem #

After releasing the initial version, a pattern quickly emerged: someone would run a comparison, spot a potential issue, and then… struggle to share it. They’d screenshot the UI, paste JSON into Slack, or ask teammates to run the same comparison themselves.

The diff engine worked great, but the workflow around it was broken. Teams needed to:

  • Reference past comparisons during incident reviews
  • Share specific analyses with colleagues before releases
  • Track which charts were changing most frequently
  • Make deployment decisions based on historical patterns, not just the current diff

I realized ChartImpact needed to evolve from a diff tool into a collaboration platform. I tackled this in three phases: persistent storage for shareable results, an interactive explorer for navigating complex diffs, and an analytics dashboard for spotting patterns over time.

Result Persistence: Durable URLs for Every Analysis #

The first addition was automatic result storage. Every comparison now gets persisted and assigned a unique URL.

Backend (Go) — The storage model and persistence logic:

type StoredResult struct {
    ID           string    `json:"id"`
    ChartName    string    `json:"chartName"`
    FromVersion  string    `json:"fromVersion"`
    ToVersion    string    `json:"toVersion"`
    Result       *DiffResult `json:"result"`
    CreatedAt    time.Time `json:"createdAt"`
    ExpiresAt    time.Time `json:"expiresAt"`
    CompressedSize int64   `json:"compressedSize,omitempty"`
}

func (s *Storage) Store(result *DiffResult, meta ComparisonMeta) (string, error) {
    // Generate deterministic ID from comparison parameters
    id := generateResultID(meta.ChartName, meta.FromVersion, meta.ToVersion)

    // Check for existing identical comparison (deduplication)
    if existing, err := s.Get(id); err == nil {
        return existing.ID, nil  // Return cached result
    }

    // Compress and store with 30-day TTL (balances storage costs with typical release cycle visibility)
    compressed, _ := gzipCompress(result)
    stored := &StoredResult{
        ID:        id,
        Result:    result,
        CreatedAt: time.Now(),
        ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
    }

    return s.backend.Save(id, stored)
}

The ID generation is deterministic: the same chart comparison always produces the same URL. This means when two team members compare ingress-nginx v4.8.0 to v4.9.0, they get the same cached result instead of running duplicate analyses.

Storage Efficiency #

Diff results can be verbose, especially for charts with many resources. To keep storage costs manageable, I implemented gzip compression on the JSON payloads.

Backend (Go) — Compression utility:

func gzipCompress(data *DiffResult) ([]byte, error) {
    var buf bytes.Buffer
    gz := gzip.NewWriter(&buf)

    if err := json.NewEncoder(gz).Encode(data); err != nil {
        return nil, err
    }
    gz.Close()

    return buf.Bytes(), nil
}

This achieves roughly 75% size reduction on typical diff outputs. A 200KB result compresses to around 50KB, making it feasible to store thousands of comparisons without breaking the budget.

Flexible Backend Options #

Not every deployment needs a database. I designed the storage layer with two backends.

Backend (Go) — Storage abstraction with pluggable implementations:

type StorageBackend interface {
    Save(id string, result *StoredResult) error
    Get(id string) (*StoredResult, error)
    List(opts ListOptions) ([]*StoredResult, error)
    Delete(id string) error
}

// Disk-based for simple deployments
type DiskBackend struct {
    basePath string
}

// PostgreSQL for teams needing analytics
type PostgresBackend struct {
    db *sql.DB
}

For local development or single-user deployments, the disk backend stores results as compressed files. For teams that want analytics and longer retention, PostgreSQL provides querying capabilities and better durability.

The Interactive Explorer #

Raw diff output is useful, but navigating large comparisons was tedious. When reviewing a 47-resource kube-prometheus-stack upgrade, I kept losing my place scrolling through walls of YAML. I needed a way to filter, search, and focus on what actually mattered.

I built an Explorer component that lets you drill into changes interactively.

Frontend (React/TypeScript) — Interactive diff explorer component:

interface ExplorerState {
  expandedResources: Set<string>;
  importanceFilter: 'all' | 'high' | 'medium' | 'low';
  categoryFilter: string | null;
  searchQuery: string;
}

function DiffExplorer({ result }: { result: DiffResult }) {
  const [state, dispatch] = useReducer(explorerReducer, initialState);

  // Client-side filtering for instant feedback
  const filteredResources = useMemo(() => {
    return result.resources
      .filter(r => matchesImportance(r, state.importanceFilter))
      .filter(r => matchesCategory(r, state.categoryFilter))
      .filter(r => matchesSearch(r, state.searchQuery));
  }, [result, state]);

  return (
    <div className="explorer">
      <FilterBar state={state} dispatch={dispatch} />
      <ResourceList resources={filteredResources} />
      <RiskSummary result={result} />
    </div>
  );
}

The filtering happens entirely client-side, which means zero latency when toggling between “show all changes” and “show only high-importance changes.” For a chart with 50+ resources, being able to instantly focus on the critical changes makes a significant difference.

Risk Summary at a Glance #

The Explorer includes a risk summary panel that aggregates change metadata.

Frontend (React/TypeScript) — Risk aggregation component:

function RiskSummary({ result }: { result: DiffResult }) {
  const summary = useMemo(() => {
    const changes = result.resources.flatMap(r => r.changes);

    return {
      high: changes.filter(c => c.importance === 'high').length,
      medium: changes.filter(c => c.importance === 'medium').length,
      low: changes.filter(c => c.importance === 'low').length,
      breakingChanges: changes.filter(c =>
        c.flags?.includes('breaking-change')
      ).length,
      availabilityRisks: changes.filter(c =>
        c.flags?.includes('availability-risk')
      ).length,
    };
  }, [result]);

  return (
    <div className="risk-summary">
      {summary.breakingChanges > 0 && (
        <Badge variant="destructive">
          {summary.breakingChanges} breaking changes
        </Badge>
      )}
      {summary.availabilityRisks > 0 && (
        <Badge variant="warning">
          {summary.availabilityRisks} availability risks
        </Badge>
      )}
    </div>
  );
}

Before diving into the diff details, you immediately see: “3 high-importance changes, 1 breaking change, 2 availability risks.” That context shapes how carefully you review the rest.

Analytics Dashboard: Patterns Over Time #

With results being stored, I had data to analyze. The analytics dashboard answers questions like:

  • Which charts are we comparing most frequently?
  • What’s the distribution of risk levels across our comparisons?
  • Are we seeing more breaking changes lately?

Backend (Go) — Analytics service that aggregates stored comparison data:

type AnalyticsService struct {
    storage StorageBackend
}

func (a *AnalyticsService) GetChartStatistics(days int) (*ChartStats, error) {
    since := time.Now().AddDate(0, 0, -days)
    results, _ := a.storage.List(ListOptions{Since: since})

    chartCounts := make(map[string]int)
    riskDistribution := make(map[string]int)

    for _, r := range results {
        chartCounts[r.ChartName]++

        for _, resource := range r.Result.Resources {
            for _, change := range resource.Changes {
                riskDistribution[change.Importance]++
            }
        }
    }

    return &ChartStats{
        MostComparedCharts: topN(chartCounts, 10),
        RiskDistribution:   riskDistribution,
        TotalComparisons:   len(results),
    }, nil
}

The frontend renders this as simple visualizations: a bar chart of most-compared charts, a pie chart of risk distribution, trend lines over time. Nothing fancy, but enough to spot patterns.

For example, if cert-manager suddenly appears in your top-5 most compared charts, that might indicate an upcoming major upgrade that multiple teams are evaluating. The analytics surface these patterns without requiring anyone to manually track them.

Production Hardening #

These features introduced new failure modes. What happens when storage is full? When the database is unreachable? When a corrupted result gets cached?

Health Checks #

I added comprehensive health endpoints that verify each component.

Backend (Go) — HTTP health check handler:

func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
    checks := []HealthCheck{
        {Name: "storage", Check: h.storage.Ping},
        {Name: "helm", Check: h.helmClient.Ping},
    }

    status := "healthy"
    results := make(map[string]string)

    for _, check := range checks {
        if err := check.Check(); err != nil {
            status = "degraded"
            results[check.Name] = err.Error()
        } else {
            results[check.Name] = "ok"
        }
    }

    json.NewEncoder(w).Encode(HealthResponse{
        Status: status,
        Checks: results,
    })
}

The health endpoint distinguishes between “healthy” (everything works), “degraded” (core functionality works, some features unavailable), and “unhealthy” (service should not receive traffic). This integrates cleanly with Kubernetes readiness probes.

Graceful Degradation #

When the storage backend is unavailable, ChartImpact continues working in stateless mode. Comparisons still run, but results aren’t persisted and the analytics dashboard shows cached data.

Backend (Go) — Service layer with graceful storage fallback:

func (s *Service) Compare(req CompareRequest) (*DiffResult, error) {
    result, err := s.diffEngine.Compare(req.From, req.To)
    if err != nil {
        return nil, err
    }

    // Attempt to store, but don't fail the request if storage is down
    if _, err := s.storage.Store(result, req.Meta); err != nil {
        log.Warn("failed to store result", "error", err)
        // Continue without storage
    }

    return result, nil
}

The core value (the diff) is always available. The additional features (persistence, analytics) enhance the experience but never block it.

What Changed in My Thinking #

Building these features shifted my perspective on developer tools:

  1. Single-player tools become multiplayer - What starts as a personal utility often needs to become a team resource. Designing for shareability from the start (deterministic IDs, durable URLs) saves significant refactoring later.

  2. Data is a byproduct worth capturing - Every comparison generates insights about how teams work. Storing and analyzing that data transforms a utility into an intelligence platform. The visibility far outweighs the marginal storage cost.

  3. Degradation paths matter - In distributed systems, partial failures are normal. Designing each feature to fail independently keeps the core value accessible even when peripheral systems are down.

Try It Out #

These features are live in ChartImpact v1.5. The quickest way to try them:

git clone https://github.com/dcotelo/ChartImpact
docker compose up

Open localhost:3000, run a comparison, and grab the shareable URL. After a few comparisons, check the analytics page to see your chart upgrade patterns emerge. The features work best when used collaboratively—share a result URL with a teammate and review it together.


ChartImpact is open source under MIT • Built with Go + Next.js

GitHub Stars License Docker Release

Questions, feedback, or feature ideas? Open an issue or reach out on X.