This is a variation on a PIVOT query (although PostgreSQL supports this via the crosstab(...) table functions).  The existing answers cover the basic technique, I just prefer to construct queries without the use of CASE, where possible.
To get started, we need a couple of things.  The first is essentially a Calendar Table, or entries from one (if you don't already have one, they're among the most useful dimension tables).  If you don't have one, the entries for the specified dates can easily be generated:
WITH Calendar_Range AS (SELECT startOfDay, startOfDay + INTERVAL '1 DAY' AS nextDay
                        FROM GENERATE_SERIES(CAST('2014-07-01' AS DATE),
                                             CAST('2014-08-01' AS DATE),
                                             INTERVAL '1 DAY') AS dr(startOfDay))
SQL Fiddle Demo
This is primarily used to create the first step in the double aggregate, like so:
SELECT Calendar_Range.startOfDay, COUNT(Log.name)
FROM Calendar_Range
LEFT JOIN Log
       ON Log.event_time >= Calendar_Range.startOfDay
          AND Log.event_time < Calendar_Range.nextDay
GROUP BY Calendar_Range.startOfDay, Log.name
SQL Fiddle Demo
Remember that most aggregate columns with a nullable expression (here, COUNT(Log.name)) will ignore null values (not count them).  This is also one of the few times it's acceptable to not include a grouped-by column in the SELECT list (normally it makes the results ambiguous).  For the actual queries I'll put this into a subquery, but it would also work as a CTE.
We also need a way to construct our COUNT ranges.  That's pretty easy too:
     Count_Range AS (SELECT text, start, LEAD(start) OVER(ORDER BY start) as next
                     FROM (VALUES('0 - 2', 0),
                                 ('3', 3),
                                 ('4+', 4)) e(text, start))
SQL Fiddle Demo 
We'll be querying these as "exclusive upper-bound" as well.  
We now have all the pieces we need to do the query.  We can actually use these virtual tables to make queries in both veins of the current answers.
First, the SUM(CASE...) style.
For this query, we'll take advantage of the null-ignoring qualities of aggregate functions again:
WITH Calendar_Range AS (SELECT startOfDay, startOfDay + INTERVAL '1 DAY' AS nextDay
                        FROM GENERATE_SERIES(CAST('2014-07-14' AS DATE),
                                             CAST('2014-07-17' AS DATE),
                                             INTERVAL '1 DAY') AS dr(startOfDay)),
     Count_Range AS (SELECT text, start, LEAD(start) OVER(ORDER BY start) as next
                     FROM (VALUES('0 - 2', 0),
                                 ('3', 3),
                                 ('4+', 4)) e(text, start))
SELECT startOfDay, 
       COUNT(Zero_To_Two.text) AS Zero_To_Two, 
       COUNT(Three.text) AS Three, 
       COUNT(Four_And_Up.text) AS Four_And_Up
FROM (SELECT Calendar_Range.startOfDay, COUNT(Log.name) AS count
      FROM Calendar_Range
      LEFT JOIN Log
             ON Log.event_time >= Calendar_Range.startOfDay
                AND Log.event_time < Calendar_Range.nextDay
      GROUP BY Calendar_Range.startOfDay, Log.name) Entry_Count
LEFT JOIN Count_Range Zero_To_Two
       ON Zero_To_Two.text = '0 - 2'
          AND Entry_Count.count >= Zero_To_Two.start 
          AND Entry_Count.count < Zero_To_Two.next 
LEFT JOIN Count_Range Three
       ON Three.text = '3'
          AND Entry_Count.count >= Three.start 
          AND Entry_Count.count < Three.next 
LEFT JOIN Count_Range Four_And_Up
       ON Four_And_Up.text = '4+'
          AND Entry_Count.count >= Four_And_Up.start
GROUP BY startOfDay
ORDER BY startOfDay
SQL Fiddle Example
The other option is of course the crosstab query, where the CASE was being used to segment the results.  We'll use the Count_Range table to decode the values for us:
SELECT startOfDay, "0 -2", "3", "4+"
FROM CROSSTAB($$WITH Calendar_Range AS (SELECT startOfDay, startOfDay + INTERVAL '1 DAY' AS nextDay
                                        FROM GENERATE_SERIES(CAST('2014-07-14' AS DATE),
                                                             CAST('2014-07-17' AS DATE),
                                                             INTERVAL '1 DAY') AS dr(startOfDay)),
                     Count_Range AS (SELECT text, start, LEAD(start) OVER(ORDER BY start) as next
                                     FROM (VALUES('0 - 2', 0),
                                                 ('3', 3),
                                                 ('4+', 4)) e(text, start))
                SELECT Calendar_Range.startOfDay, Count_Range.text, COUNT(*) AS count
                FROM (SELECT Calendar_Range.startOfDay, COUNT(Log.name) AS count
                      FROM Calendar_Range
                      LEFT JOIN Log
                             ON Log.event_time >= Calendar_Range.startOfDay
                                AND Log.event_time < Calendar_Range.nextDay
                      GROUP BY Calendar_Range.startOfDay, Log.name) Entry_Count
                JOIN Count_Range
                  ON Entry_Count.count >= Count_Range.start
                     AND (Entry_Count.count < Count_Range.end OR Count_Range.end IS NULL)
                GROUP BY Calendar_Range.startOfDay, Count_Range.text
                ORDER BY Calendar_Range.startOfDay, Count_Range.text$$,
              $$VALUES('0 - 2', '3', '4+')$$) Data(startOfDay DATE, "0 - 2" INT, "3" INT, "4+" INT)
(I believe this is correct, but don't have a way to test it - Fiddle doesn't seem to have the crosstab functionality loaded.  In particular, CTEs probably must go inside the function itself, but I'm not sure....)