Add first cut of the crash grouping feature
authorVincent Tondellier <tonton+hg@team1664.org>
Mon, 28 Dec 2015 23:08:23 +0100
changeset 88 c82f5589db11
parent 87 03caeafba2ff
child 89 d84db31dd51c
Add first cut of the crash grouping feature
lib/CrashTest.pm
lib/CrashTest/Command/get_trace.pm
lib/CrashTest/Controller/CrashGroups.pm
lib/CrashTest/Helper/Stats.pm
lib/CrashTest/Model/CrashGroup.pm
lib/CrashTest/Plugin/CrashSignatureExtractor/C_Cpp.pm
lib/CrashTest/Plugin/Storage/Sql/Model/CrashGroup.pm
lib/CrashTest/Plugin/Storage/Sql/Model/CrashReport.pm
lib/CrashTest/Plugin/Storage/Sql/migrations_pg.sql
templates/group/_stats.html.ep
templates/group/show.html.ep
templates/groups/index.html.ep
--- a/lib/CrashTest.pm	Mon Dec 28 23:07:16 2015 +0100
+++ b/lib/CrashTest.pm	Mon Dec 28 23:08:23 2015 +0100
@@ -4,6 +4,7 @@
 use CrashTest::Model::Storage;
 use CrashTest::Model::StackFilter;
 use CrashTest::Model::CrashReport;
+use CrashTest::Model::CrashGroup;
 use CrashTest::Model::CrashProcessor;
 
 # This method will run once at server start
@@ -27,9 +28,11 @@
     $self->plugin("CrashTest::Helper::DateTime");
     $self->plugin("CrashTest::Helper::Backtrace");
     $self->plugin("CrashTest::Helper::XmlEscape");
+    $self->plugin("CrashTest::Helper::Stats");
 
 
     $self->helper(crash_reports     => sub { state $crash_reports   = CrashTest::Model::CrashReport->new    (app => $self); });
+    $self->helper(crash_groups      => sub { state $crash_groups    = CrashTest::Model::CrashGroup->new     (app => $self); });
 
     $self->helper(crash_processor   => sub { state $crash_processor = CrashTest::Model::CrashProcessor->new (app => $self, config => $self->config); });
     $self->helper(stackfilter       => sub { state $crash_reports   = CrashTest::Model::StackFilter->new    (app => $self, config => $self->config); });
@@ -45,7 +48,10 @@
 
     # Normal route to controller
     $r->get('/')->to('crash_reports#index')->name('index');
-    $r->get('/report/:uuid' => [ uuid => qr/[0-9a-fA-F-]+/ ])->to('crash_reports#report')->name('report');
+    $r->get('/reports')->to('crash_reports#index')->name('reports');
+    $r->get('/groups')->to('crash_groups#index')->name('groups');
+    $r->get('/groups/:uuid' => [ uuid => qr/[0-9a-fA-F-]+/ ])->to('crash_groups#show')->name('group');
+    $r->get('/report/:uuid' => [ uuid => qr/[0-9a-fA-F-]+/ ])->to('crash_reports#show')->name('report');
 
     $r->post('/submit')->to('crash_inserter#insert');
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/CrashTest/Command/get_trace.pm	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,55 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package CrashTest::Command::get_trace;
+use Mojo::Base 'Mojolicious::Command';
+use Mojo::JSON qw/decode_json/;
+use Mojo::Util qw/slurp/;
+use File::Spec::Functions qw/catdir catfile/;
+use File::Basename;
+
+use CrashTest::Model::Thread;
+use CrashTest::Plugin::CrashSignatureExtractor::C_Cpp;
+
+# Short description
+has description => 'Get crash signature';
+
+# Short usage message
+has usage => <<EOF;
+Usage: APPLICATION get_trace file ...
+EOF
+
+sub run {
+    my ($self, @args) = @_;
+
+    if(scalar @args < 1) {
+        say $self->usage;
+        exit 0;
+    }
+
+    foreach my $jsonfile(@args) {
+        my $j = decode_json(slurp($jsonfile));
+
+        my $raw_thread = $j->{threads}->[$j->{crashing_thread}->{threads_index}];
+        my $thread = CrashTest::Model::Thread->new($raw_thread);
+
+        my $sig_extract = CrashTest::Plugin::CrashSignatureExtractor::C_Cpp->new(
+            app => $self->app,
+            config => $self->app->config->{Processor}->{CrashSignatureExtractor}->{C_Cpp}
+        );
+        say join "\n", @{$sig_extract->extract_signature($thread)};
+    }
+
+}
+
+1;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/CrashTest/Controller/CrashGroups.pm	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,70 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package CrashTest::Controller::CrashGroups;
+use Mojo::Base 'Mojolicious::Controller';
+use Mojo::Util qw/dumper/;
+
+sub index {
+    my $self = shift;
+
+    my $page = 1;
+    my $crashs_per_page = 25;
+
+    $self->validation->required('page')->like(qr/^[0-9]+$/);
+    $page = scalar $self->validation->param("page") if $self->validation->is_valid('page');
+
+    my ($results, $pager) = $self->crash_groups->index($page, $crashs_per_page, $self->req->param('search'));
+
+    #$self->app->log->debug(dumper $results);
+
+    $self->stash(crashs => $results);
+    $self->stash(pager => $pager);
+    $self->stash(extra_columns => []);
+
+    $self->render("groups/index");
+}
+
+sub show {
+    my $self = shift;
+
+    my $uuid = $self->param('uuid');
+
+    my $group = $self->app->crash_groups->get($uuid);
+    $self->stash(group => $group->{group});
+    $self->stash(stats_by_product_and_version => $group->{stats_by_product_and_version});
+
+    my $page = 1;
+    my $crashs_per_page = 20;
+    $self->validation->required('page')->like(qr/^[0-9]+$/);
+    $page = scalar $self->validation->param("page") if $self->validation->is_valid('page');
+
+    my $search = $self->req->param('search');
+    if(defined($search) && $search ne "") {
+        $search .= " AND " . "group_id=$group->{group}->{id}";
+    } else {
+        $search = "group_id=$group->{group}->{id}";
+    }
+
+    my ($results, $pager) = $self->crash_reports->index($page, $crashs_per_page, $search);
+
+    #$self->app->log->debug(dumper $results);
+
+    $self->stash(crashs => $results);
+    $self->stash(pager => $pager);
+    $self->stash(extra_columns => $self->app->config->{WebInterface}->{ExtraColumns}->{Index});
+
+    $self->render("group/show");
+}
+
+1;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/CrashTest/Helper/Stats.pm	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,39 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package CrashTest::Helper::Stats;
+use Mojo::Base 'Mojolicious::Plugin';
+use Mojo::ByteStream qw/b/;
+
+sub register {
+    my ($self, $app, $conf) = @_;
+
+    $app->helper(stats_bar => sub { $self->_stats_bar(@_) } );
+}
+
+sub _stats_bar {
+  my ($self, $c, $val, $max) = @_;
+  my $pct = sprintf("%.2f", $val / $max * 100.0);
+
+    return b(
+<<EOF
+<div class="progress stats">
+  <div class="progress-bar" role="progressbar" aria-valuenow="$val" aria-valuemin="0" aria-valuemax="$max" style="min-width: 2em; width: $pct%;">
+    $val
+  </div>
+</div>
+EOF
+);
+}
+
+1;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/CrashTest/Model/CrashGroup.pm	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,31 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package CrashTest::Model::CrashGroup;
+use Mojo::Base -base;
+
+has [ qw/app config/ ];
+
+sub index {
+    my ($self, $page, $nperpage, $search_str) = @_;
+
+    return $self->app->storage->first("CrashGroup::index", $page, $nperpage, $search_str);
+}
+
+sub get {
+    my ($self, $uuid) = @_;
+
+    return $self->app->storage->first("CrashGroup::get", $uuid);
+}
+
+1;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/CrashTest/Plugin/CrashSignatureExtractor/C_Cpp.pm	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,115 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package CrashTest::Plugin::CrashSignatureExtractor::C_Cpp;
+use Mojo::Base -base;
+use Mojo::Util qw/dumper/;
+use Text::Balanced qw/extract_bracketed/;
+
+use CrashTest::Model::Thread;
+
+has [ qw/app config/ ];
+
+sub extract_signature {
+    my ($self, $thread) = @_;
+
+    my $frames = $thread->frames;
+
+    $frames = _strip_top_frames($frames, $self->config->{TopIrrelevant}, $self->config->{TopFrame});
+
+    #my @rev_frames = reverse @$frames;
+    #    $frames = _strip_top_frames(\@rev_frames, $self->config->{BottomIrrelevant}, $self->config->{BottomFrame});
+
+    #my @new_frames = reverse @$frames;
+    my @new_frames = @$frames;
+
+    my @short_frames;
+    foreach my $frame(@new_frames) {
+        my $short = _extract_function_name($frame->function);
+        $short =~ s/^$_// foreach (@{$self->config->{RemoveNamespace}});
+        push @short_frames, $short;
+        # . "@" . $frame->function_offset;
+    }
+
+    return \@short_frames;
+}
+
+sub extract_signature_title {
+    my ($self, $thread) = @_;
+
+    return $self->extract_signature($thread)->[0];
+}
+
+sub _extract_function_name {
+    my $signature = shift;
+
+    return "" if(!defined($signature) || $signature eq "");
+
+    my $short_signature = "";
+    my $text = $signature;
+    do {
+        my ($str, $next, $prefix) = extract_bracketed($text, '<(', '[^<(]*');
+        if($str) {
+            $short_signature .= $prefix;
+            $text = $next;
+        } else {
+            $short_signature .= $next;
+            $text = undef;
+        }
+    } while($text);
+
+    return $short_signature;
+}
+
+sub _smartmatch {
+    my ($text, $pat) = @_;
+    return $text =~ $pat if(ref($pat) eq "Regexp");
+    return $text eq $pat;
+}
+
+sub _strip_top_frames {
+    my ($frames, $skip, $stop) = @_;
+
+    my $first_frame = 0;
+    my $i = -1;
+    TOPFRAME: foreach my $frame(@$frames) {
+        my $f = _extract_function_name($frame->function);
+        $i += 1;
+
+        # if matching, mark first frame, and stop
+        foreach my $m(@{$stop}) {
+            if(_smartmatch($f, $m)) {
+                $first_frame = $i;
+                last TOPFRAME;
+            }
+        }
+        # if matching, mark next frame as first, and continue
+        my $skip_matched = 0;
+        foreach my $m(@{$skip}) {
+            if(_smartmatch($f, $m)) {
+                $first_frame = $i + 1;
+                $skip_matched = 1;
+            }
+        }
+        # else, stop
+        unless($skip_matched) {
+            last TOPFRAME;
+        }
+    }
+
+    my $last = scalar(@$frames) - 1;
+    my @new_frames = @$frames[$first_frame .. $last];
+    return \@new_frames;
+}
+
+1;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/CrashTest/Plugin/Storage/Sql/Model/CrashGroup.pm	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,194 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package CrashTest::Plugin::Storage::Sql::Model::CrashGroup;
+use Mojo::Base -base;
+#use DBI::Log;
+#$DBI::Log::trace = 0;
+
+use Storable 'dclone';
+use CrashTest::Model::Thread;
+use CrashTest::Plugin::CrashSignatureExtractor::C_Cpp;
+
+
+has [ qw/instance app config/ ];
+
+has qw/db/;
+
+sub new {
+    my $self = shift->SUPER::new(@_);
+
+    $self->db($self->instance->dbh->db);
+
+    return $self;
+}
+
+sub _build_query_from_search_string {
+    my ($self, $search) = @_;
+
+    my @values;
+
+    # define a callback to collect values safely (without magic markers)
+    my $cb = sub {
+        my ($column, $this_op, $value) = @_;
+        if($column->type eq "fuzzy") {
+            $this_op = " ILIKE ";
+            $value = "%$value%";
+        }
+        push @values, $value;
+        return join('', $column->stringify, $this_op, '?');
+    };
+
+    my $search_fields = {
+        user_id     => { callback => $cb, name => 'crash_user.user_id' },
+        product     => { callback => $cb, name => 'product.name' },
+        version     => { callback => $cb, name => 'product.version' },
+        channel     => { callback => $cb, name => 'product.release_channel' },
+        function    => { callback => $cb, name => 'crash_group.crash_thread_signature_bt', type => "fuzzy" },
+    };
+
+    my $parser = Search::QueryParser::SQL->new(
+        columns         => $search_fields,
+        default_column  => "function",
+        strict          => 1,
+    );
+
+    my $query = $parser->parse($search, 1)
+        or die "Error in query: " . $parser->err;
+
+    # reset before calling dbi
+    @values = ();
+    my $dbi = $query->dbi();
+
+    return [ $dbi->[0], \@values ];
+}
+
+sub index {
+    my ($self, $pagen, $nperpage, $search_str) = @_;
+
+    my $where = "";
+    my @values = ();
+    if(defined($search_str) && $search_str ne "") {
+        my $q = $self->_build_query_from_search_string($search_str);
+        $where = "WHERE " . $q->[0];
+        @values = @{$q->[1]};
+    }
+
+    my $count = $self->db->query("
+        SELECT count(distinct(crash_group_id)) AS total
+        FROM crash_reports
+        JOIN crash_users AS crash_user ON crash_reports.crash_user_id = crash_user.id
+        JOIN products AS product ON crash_reports.product_id = product.id
+        JOIN crash_groups AS crash_group ON crash_reports.crash_group_id = crash_group.id
+        $where
+        ", @values)->hash;
+
+    my $pager = Data::Page->new();
+    $pager->total_entries($count->{total});
+    $pager->entries_per_page($nperpage);
+    $pager->current_page($pagen);
+
+    my $results = $self->db->query("
+        SELECT crash_groups.uuid, title, group_by_count.*
+        FROM crash_groups,
+             ( SELECT crash_group_id AS id,
+                    min(version) AS first_version,
+                    max(version) AS last_version,
+                    string_agg(distinct(name), ', ') AS product_names,
+                    count(*) AS crash_count
+               FROM crash_reports
+               JOIN crash_users AS crash_user ON crash_reports.crash_user_id = crash_user.id
+               JOIN products AS product ON crash_reports.product_id = product.id
+               JOIN crash_groups AS crash_group ON crash_reports.crash_group_id = crash_group.id
+               $where
+               GROUP BY crash_group_id ) AS group_by_count
+        WHERE group_by_count.id = crash_groups.id
+        ORDER BY crash_count DESC, crash_groups.id DESC
+        OFFSET (?) ROWS
+        FETCH NEXT (?) ROWS ONLY
+        ",
+        @values, $pager->skipped, $pager->entries_per_page
+    )->hashes;
+
+    return ($results, $pager);
+}
+
+sub get {
+    my ($self, $uuid) = @_;
+
+    my $result = $self->db->query("
+        SELECT * FROM crash_groups WHERE uuid = ?
+        ",
+        $uuid
+    )->hash;
+
+    return {
+        group => $result,
+        stats_by_product_and_version => $self->_stats_by_product_and_version($result->{id})
+    };
+}
+
+sub _stats_by_product_and_version {
+    my ($self, $crash_id) = @_;
+    my $results = $self->db->query("
+        SELECT
+            name AS product_name,
+            version,
+            count(*) AS crash_count
+        FROM crash_reports
+        JOIN products ON product_id = products.id
+        WHERE crash_group_id = ?
+        GROUP BY product_name, version
+        ORDER BY crash_count DESC, product_name, version;
+        ",
+        $crash_id
+    )->hashes;
+    return $results;
+}
+
+
+sub find_or_create {
+    my ($self, $uuid, $pjson) = @_;
+
+    my $max_dist = $self->app->config->{Processor}->{CrashSignatureExtractor}->{C_Cpp}->{GroupMaxDistance} || 0.1;
+
+    my $sig_extract = CrashTest::Plugin::CrashSignatureExtractor::C_Cpp->new(
+        app => $self->app,
+        config => $self->app->config->{Processor}->{CrashSignatureExtractor}->{C_Cpp}
+    );
+
+    my $raw_thread = dclone $pjson->{threads}->[$pjson->{crashing_thread}->{threads_index}];
+    my $thread = CrashTest::Model::Thread->new($raw_thread);
+
+    my $sig = join "\n", @{$sig_extract->extract_signature($thread)};
+
+    my $crash_group;
+    if($sig ne "") {
+        $crash_group = $self->db->query(
+            "SELECT id, crash_thread_signature_bt <-> ? AS dist FROM crash_groups ORDER BY dist LIMIT 1",
+            $sig
+        )->hash;
+
+        # race condition here ...
+
+        if(!$crash_group || $crash_group->{dist} > $max_dist) {
+            $crash_group = $self->db->query(
+                "INSERT INTO crash_groups (uuid, crash_thread_signature_bt, title) VALUES (?, ?, ?) RETURNING id",
+                $uuid, $sig, $sig_extract->extract_signature_title($thread)
+            )->hash;
+        }
+    }
+    return $crash_group;
+}
+
+1;
--- a/lib/CrashTest/Plugin/Storage/Sql/Model/CrashReport.pm	Mon Dec 28 23:07:16 2015 +0100
+++ b/lib/CrashTest/Plugin/Storage/Sql/Model/CrashReport.pm	Mon Dec 28 23:08:23 2015 +0100
@@ -20,14 +20,18 @@
 #$DBI::Log::trace = 0;
 use Search::QueryParser::SQL;
 
+use CrashTest::Plugin::Storage::Sql::Model::CrashGroup;
+
 has [ qw/instance app config/ ];
 
 has qw/db/;
+has qw/crash_groups/;
 
 sub new {
     my $self = shift->SUPER::new(@_);
 
     $self->db($self->instance->dbh->db);
+    $self->crash_groups(CrashTest::Plugin::Storage::Sql::Model::CrashGroup->new(@_));
 
     return $self;
 }
@@ -40,7 +44,6 @@
     # define a callback to collect values safely (without magic markers)
     my $cb = sub {
         my ($column, $this_op, $value) = @_;
-        say $column;
         if($column->type eq "fuzzy") {
             $this_op = " ILIKE ";
             $value = "%$value%";
@@ -50,11 +53,12 @@
     };
 
     my $search_fields = {
-        user_id     => { callback => $cb, name => 'u.user_id' },
-        product     => { callback => $cb, name => 'p.name' },
-        version     => { callback => $cb, name => 'p.version' },
-        channel     => { callback => $cb, name => 'p.release_channel' },
-        function    => { callback => $cb, name => 'extract_crashing_functions(d.processed)', type => "fuzzy" },
+        user_id     => { callback => $cb, name => 'crash_user.user_id' },
+        product     => { callback => $cb, name => 'product.name' },
+        version     => { callback => $cb, name => 'product.version' },
+        channel     => { callback => $cb, name => 'product.release_channel' },
+        group_id    => { callback => $cb, name => 'crash_reports.crash_group_id' },
+        function    => { callback => $cb, name => 'crash_group.crash_thread_signature_bt', type => "fuzzy" },
     };
 
     my $parser = Search::QueryParser::SQL->new(
@@ -68,8 +72,9 @@
 
     # reset before calling dbi
     @values = ();
+    my $dbi = $query->dbi();
 
-    return [ $query->dbi->[0], \@values ];
+    return [ $dbi->[0], \@values ];
 }
 
 sub index {
@@ -85,9 +90,9 @@
 
     my $count = $self->db->query("
         SELECT count(crash_reports.id) AS total FROM crash_reports
-        JOIN crash_users AS u ON crash_reports.crash_user_id = u.id
-        JOIN products AS p ON crash_reports.product_id = p.id
-        JOIN crash_report_datas AS d ON crash_reports.id = d.crash_report_id
+        JOIN crash_users AS crash_user ON crash_reports.crash_user_id = crash_user.id
+        JOIN products AS product ON crash_reports.product_id = product.id
+        JOIN crash_groups AS crash_group ON crash_reports.crash_group_id = crash_group.id
         $where
         ", @values)->hash;
 
@@ -96,14 +101,21 @@
     $pager->entries_per_page($nperpage);
     $pager->current_page($pagen);
 
+    my @extra_cols;
+    foreach my $extra_col(@{$self->app->config->{WebInterface}->{ExtraColumns}->{Index}}) {
+        push @extra_cols, $extra_col->{db_column} . " AS " . $extra_col->{id};
+    }
+    my $extra_columns = join(",", @extra_cols);
+
     my $results = $self->db->query("
         SELECT  crash_reports.*,
-                p.distributor AS p_distributor, p.name AS p_name, p.version AS p_version, p.release_channel AS p_release_channel,
-                u.os AS u_os, u.cpu_arch AS u_cpu_arch, u.cpu_count AS u_cpu_count, u.extra_info AS u_extra_info
+                product.distributor AS p_distributor, product.name AS p_name, product.version AS p_version, product.release_channel AS p_release_channel,
+                crash_user.os AS u_os, crash_user.cpu_arch AS u_cpu_arch, crash_user.cpu_count AS u_cpu_count, crash_user.extra_info AS u_extra_info,
+                $extra_columns
         FROM crash_reports
-        JOIN crash_users AS u ON crash_reports.crash_user_id = u.id
-        JOIN products AS p ON crash_reports.product_id = p.id
-        JOIN crash_report_datas AS d ON crash_reports.id = d.crash_report_id
+        JOIN crash_users AS crash_user ON crash_reports.crash_user_id = crash_user.id
+        JOIN products AS product ON crash_reports.product_id = product.id
+        JOIN crash_groups AS crash_group ON crash_reports.crash_group_id = crash_group.id
         $where
         ORDER BY crash_time DESC
         OFFSET (?) ROWS
@@ -144,6 +156,8 @@
         $crash_time = DateTime->from_epoch(epoch => $client_info->{CrashTime});
     }
 
+    chomp($client_info->{ProductName});
+    chomp($client_info->{Version}) if $client_info->{Version};
     my @product_values = (
         $client_info->{Distributor},
         $client_info->{ProductName},
@@ -176,6 +190,8 @@
         )->hash;
     }
 
+    my $crash_group = $self->crash_groups->find_or_create($uuid, $pjson);
+
     my $main_module;
     {
         my $i = $pjson->{main_module};
@@ -183,8 +199,11 @@
     }
 
     my $dbcrash = $self->db->query(
-        "INSERT INTO crash_reports (start_time, crash_time, uuid, main_module, product_id, crash_user_id) VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
-        $start_time, $crash_time, $uuid, $main_module, $dbproduct->{id}, $dbuser->{id}
+        "INSERT INTO crash_reports (start_time, crash_time, uuid, main_module, product_id, crash_user_id, crash_group_id, crash_group_distance)
+         VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
+        $start_time, $crash_time, $uuid, $main_module, $dbproduct->{id}, $dbuser->{id},
+        $crash_group ? $crash_group->{id} : undef,
+        $crash_group ? ($crash_group->{dist} || 0) : undef,
     )->hash;
 
     $self->db->query(
--- a/lib/CrashTest/Plugin/Storage/Sql/migrations_pg.sql	Mon Dec 28 23:07:16 2015 +0100
+++ b/lib/CrashTest/Plugin/Storage/Sql/migrations_pg.sql	Mon Dec 28 23:08:23 2015 +0100
@@ -59,7 +59,7 @@
 $$
 LANGUAGE sql IMMUTABLE;
 
--- This extension is the contrib modules
+-- This extension is in contrib/
 CREATE EXTENSION IF NOT EXISTS pg_trgm;
 
 CREATE INDEX crash_report_datas_idx_extract_crashing_functions ON crash_report_datas USING gist (
@@ -75,5 +75,35 @@
 DROP TABLE crash_users CASCADE;
 DROP FUNCTION extract_crashing_functions (processed_json jsonb);
 
+-- ###########################################################################
+-- 2 up
+-- ###########################################################################
+
+CREATE TABLE "crash_groups" (
+    "id" serial NOT NULL,
+    "uuid" uuid NOT NULL,
+    "title" character varying NOT NULL,
+    "crash_thread_signature_bt" text NOT NULL,
+    CONSTRAINT "crash_groups_uuid_idx" UNIQUE ("uuid"),
+    PRIMARY KEY ("id")
+);
+
+DROP INDEX crash_report_datas_idx_extract_crashing_functions;
+CREATE INDEX crash_groups_idx_crash_thread_signature_bt ON crash_groups USING gist (
+    crash_thread_signature_bt gist_trgm_ops
+);
+
+ALTER TABLE "crash_reports" ADD COLUMN crash_group_id integer;
+ALTER TABLE "crash_reports" ADD COLUMN crash_group_distance real;
+ALTER TABLE "crash_reports" ADD CONSTRAINT "crash_reports_fk_crash_group_id" FOREIGN KEY ("crash_group_id")
+  REFERENCES "crash_groups" ("id") ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE;
+
+-- ###########################################################################
+-- 2 down
+-- ###########################################################################
+
+ALTER TABLE "crash_reports" DROP COLUMN crash_group_id;
+ALTER TABLE "crash_reports" DROP COLUMN crash_group_distance;
+DROP TABLE "crash_groups" CASCADE;
 
 -- vim:ft=pgsql:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/templates/group/_stats.html.ep	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,26 @@
+<a data-toggle="collapse" href="#stats-by-product-and-version"><h3>Crashs by product and version</h3></a>
+<div id="stats-by-product-and-version" class="collapse in">
+%= t table => (class => 'table table-striped table-hover table-bordered table-condensed') => begin
+<thead>
+  <tr>
+    <th>Product</th>
+    <th>Version</th>
+    <th>Count</th>
+  </tr>
+</thead>
+<tbody>
+% my $max;
+% foreach my $stat(@{$stats_by_product_and_version}) {
+  % $max = $stat->{crash_count} unless defined($max);
+  %= t tr => begin
+    %= t td => $stat->{product_name}
+    %= t td => $stat->{version}
+    %= t td => begin
+      %= stats_bar($stat->{crash_count}, $max)
+    % end
+  % end
+% }
+</tbody>
+% end
+</div>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/templates/group/show.html.ep	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,23 @@
+% title "Crash for \"$group->{title}\"";
+% layout 'main';
+<h2>Crashs for <%= $group->{title} %></h2>
+
+%= t div => (class => 'tabbable') => begin
+  %= t ul => (class => 'nav nav-tabs', id => 'report-tabs') => begin
+    %= t li => (class => 'active') => begin
+      %= t a => (href => '#stats', 'data-toggle' => 'tab') => 'Statistics'
+    % end
+    %= t li => begin
+      %= t a => (href => '#reports', 'data-toggle' => 'tab') => 'Reports'
+    % end
+  % end
+
+  %= t div => (class => 'tab-content') => begin
+    %= t div => (class => 'tab-pane active', id => 'stats') => begin
+      %= include('group/_stats', stats_by_product_and_version => $stats_by_product_and_version);
+    % end
+    %= t div => (class => 'tab-pane', id => 'reports') => begin
+      %= include('reports/_list', crashs => $crashs, extra_columns => $extra_columns, pager => $pager);
+    % end
+  % end
+% end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/templates/groups/index.html.ep	Mon Dec 28 23:08:23 2015 +0100
@@ -0,0 +1,28 @@
+% title 'Top crashs';
+% layout 'main';
+%= t table => (class => 'table table-striped table-hover table-bordered table-condensed') => begin
+<thead>
+  <tr>
+    <th>Title</th>
+    <th>Count</th>
+    <th>First version seen</th>
+    <th>Last version seen</th>
+    <th>Products</th>
+  </tr>
+</thead>
+% foreach my $crash(@$crashs) {
+  %= t tr => begin
+    %= t td => begin
+      %= link_to $crash->{title} => url_for('group', uuid => $crash->{uuid})
+    % end
+    %= t td => $crash->{crash_count}
+    %= t td => $crash->{first_version}
+    %= t td => $crash->{last_version}
+    %= t td => $crash->{product_names}
+  % end
+% }
+% end
+
+% if($pager->first_page != $pager->last_page) {
+  %= bootstrap_pagination($pager->current_page, $pager->last_page);
+% }