chcesz się nauczyć ruby on rails?

od jakiegoś czasu strasznie modny stał się framework do robienia aplikacji webowych – ruby on rails.

sporo ludzi robi tutoriale, opisy, filmy pokazujące co i jak można.

econsultant.com przygotowali gotową do użytku listę 30 najlepszych (w/g nich) tutoriali nt. ror'a. warto spojrzeć – google na hasło “ruby on rails" tutorial, znajduje ponad 1.7 miliona stron. dzięki econsultantom można ich przejrzeć zdecydowanie mniej 🙂

jesteś człowiekiem czy robotem?

wiele serwisów internetowych broni sie przed np. masową rejestracją kont poprzez umieszczanie na formularzach tzw. captchy. czyli obrazków przedstawiających zniekształcone litery i cyfry. a użytkownik musi napisać jakie znaki tam sa by zostać uznanym za człowieka i by móc dalej rejestrawać konto czy wykonywać inną, chronioną, akcję.

ostatnio powstał serwis który oferuje captche które są jakby ciekawsze. trzeba wybrać które 3 zdjęcia (z 9) przedstawiają osoby “hot" – gorące, urodziwe. jest wersja dla facetów i dla kobiet.

kiedy yahoo zmieni swoje captche na takie?

jak zaprojektować bazę danych do …?

na newsach na grupie pl.comp.bazy-danych co jakiś czas ktoś się pyta jak zaprojektować bazę do … (wypozyczalni książek, serwisu, whatevera).

zazwyczaj są to prośby/pytania wynikające z nieuczenia się i zbliżającej się (na przykład – już jutro) sesji egzaminacyjnej.

tym co się nie uczą to i tak niewiele pomoże, ale jeśli macie problem jak zacząć projekt bazy do jakiegoś zadania – spójrzcie na ten serwis. zawiera sporo (około 200 chyba) przykładowych struktur baz danych.

oczywiście tego co tam jest nie wolno traktować jako wyroczni. ale czasem po prostu jest potrzebny taki kopniak na początek – a dalej już można modyfikować przykładowy schemat do woli.

uważaj co piszesz – może ci to zamknąć drogę do kariery

w przeprowadzonym ostatnio badaniu 35% osób zajmujących się rekrutacją przyznało, że odrzucali kandydatury pracowników na bazie informacji znalezionych w sieci.

rok temu było to 26% czyli trend ewidentnie zwyżkuje – co jest tak naprawdę dosyć oczywiste biorąc pod uwagę powszechność internetu.

co interesujące – 82% przepytanych osób spodziewa się, że osoba rekrutująca wyszuka informacji o nich w sieci, ale jedynie co trzeci sprawdzał sam informacje o sobie.

są też sytuacje odwrotne – gdy informacje znalezione na sieci pozwalają się lepiej zaprezentować lub też są startem do dyskusji w trakcie rozmowy kwalifikacyjnej, co pozwala nieco “rozluźnić atmosferę".

część ludzi omija ten problem pisząc na sieci pod pseudonimami lub anonimowo, ale trzeba zwrócić uwagę, że zamyka to też możliwość, skojarzenia przez przyszłego pracodawcę informacji pozytywnej o kandydacie.

wydaje się, że jedyną rozsądną opcją, jest po prostu zachowywanie się w sieci tak jak w normalnym życiu – pewne normy zachowania obowiązują. to da bezpieczeństwo, że nikt nie wygrzebie “brudów" z sieci.

komputer czytający myśli nadzieją dla okaleczonych

w 1996 roku kilku naukowców zaczęło pracować nad systemem umożliwiającym odczytywanie bezpośrednio z mózgu prostych komend oraz ich przetwarzanie na postać cyfrową.

w 2001 założyli firmę, zdobyli 5milionów dolarów kapitału i wystąpili do fda o zgodę na badania kliniczne. w 2002 udało im sie spowodować, że małpy z wszczepionym chipem mogłu kontrolować kursor na ekranie komputera nie wykonując żadnego ruchu.

w 2004 rozpoczęły się testy kliniczne na ludziach.

chip jest inwazyjny – ma wielkość 4mm x 4mm i jest “zanurzony" na głębokość 1mm w mózg, w obszar odpowiedzialny za świadomy ruch.

wyłapuje sygnały, które (w fazie treningu) są przetwarzane na ruch w dwóch wymiarach.

testy są bardzo obiecujące – pierwszy pacient potrafi już przy pomocy myśli narysować kółko w programie graficznym, zagrać w ponga czy modyfikować głośność telewizji – nawet jeśli w czasie wykonywania tego rozmawia!

na razie testy wykonuje się na osobach chorych – pierwszy pacjent, mający 25 lat, ma jakieś (kiepsko rozumiem angielski medyczny) poważne uszkodzenia kręgosłupa/rdzenia nerwowego i jest sparaliżowany od 2001 roku.

na razie technologia jest jeszcze w powijakach. poza małym chipem do działania wymaga olbrzymiego sprzętu obok, długiego treningu (wzory aktywności neuronów mogą się zmieniać z dnia na dzień, ze względu na pogodę, humor czy cokolwiek innego). ale już teraz wygląda to mocno obiecująco. następny krok to spowodowanie by chip działał bezprzewodowo i rozbudowanie logiki odpowiedzialnej za rozpoznawanie wzorów aktywności. dzięki temu będzie możliwe np. zrobienie wózka inwalidzkiego sterowanego myślami chorego.

to jedna strona medalu. druga jest taka, że taka technologia ma całkiem inne, i też interesujące, zastosowania wojskowe. w dodatku – wojsko ma wyższy budżet. kiedy się dowiemy, że wojsko nad tym pracuje? a może pracuje od dawna, tylko jest to tajne?

jak monitorować postgresa?

po udanym postawieniu serwera, przychodzi kolej na to by go nadzorować. spora część ludzi używa w tym celu softu który automatycznie robi wykresy różnych parametrów. przykładem (którego używam np. ja) jest mrtg. są też inne pakiety, lepsze czy gorsze. używam i lubię mrtg.

mrtg potrafi rysować wykresy nie tylko ruchu na interfejsach, ale praktycznie dowolnych danych podawanych w postaci liczbowej.

dzięki temu używam go do rysowania wykresów:

  • loadu systemowego
  • zużycia pamięci
  • zużycia procesorów
  • wolnego miejsca na dyskach
  • ruchu na interfejsach sieciowych
  • ruchu na swapie (in/out)

to monitoruję na każdym serwerze – niezależnie czy to webserwer, serwer aplikacyjny z jbossem, webproxy, firewall czy serwer bazodanowy.

ale na serwerach bazodanowych przydają się też inne parametry.

zanim przejdę do ich omówienia krótki wtręt.

mrtg potrafi pobierać dane poprzez uruchamianie programów które mu dane zwracają, lub poprzez odpytywanie zdalnego serwera snmp. wybrałem to drugie rozwiązanie, bo daje mi trochę więcej możliwości – np. mogę wykresy rysować na zupelnie innej maszynie niż ta której wykresy dotyczą.

do każdej znanej mi implementacji snmp jest możliwość podpięcia zewnętrznych skryptów/programów pod tzw. mib'y. dzięki temu mogę dowolnie rozszerzać funkcjonalność snmp i dodawać monitorowanie parametrów o których twórcy snmpd nie mieli pojęcia 🙂

pierwszym niestandardowym parametrem który monitoruję na serwerach bazodanowych jest ilość operacji i/o w podsystemie dyskowym.

postgresql jest mocno czuły na obciążenie dysków i tzw. iowaity powodują, że maszyna zachowuje się jakby ktoś jej podciął skrzydła. procesor jest wolny, wszystko działa ok, ale postgres się wlecze.

w używanym przeze mnie net-snmpd, odpowiednie wartości są dostępne pod mib'ami: ssIORawSent.0 i ssIORawReceived.0.

odpowiedni kawałek configu mrtg:

Target[db_sys_iorawsent]: ssIORawSent.0&ssIORawSent.0:public@db
Options[db_sys_iorawsent]: growright
MaxBytes[db_sys_iorawsent]: 100000000
Title[db_sys_iorawsent]: IORawSent at db
PageTop[db_sys_iorawsent]: IORawSent at db

(można umieścić oba wykresy na jednym obrazku, ale z przyczyn pozamerytorycznych wolę mieć każdy oddzielnie).

następnymi (i ostatnimi ogólno systemowymi) parametrami jakie monitoruję jest ilość context-switchy i przerwań w systemie.

dostępne jest to u mnie pod mibami: ssRawContexts.0 i ssRawInterrupts.0

i teraz przechodzimy do części najzabawniejszych.

co monitorować w samym postgresie.

przede wszystkim – jak? przygotowałem sobie prosty program który wykonuje jedno zadane polecenie w bazie danych i zwraca wynik na stdout. ten skrypt podłączam do snmp wykorzystując funkcję “exec" z snmpd.conf.

i lecimy z zapytaniami:

  1. select pg_database_size(‘nazwa_bazy_której_wielkość_chcesz_monitorować');
    zwraca wielkość bazy w bajtach
  2. select cast(extract(epoch from now() – query_start) * 1000 as int8) from pg_stat_activity where current_query !~ ‘stats_command_string w postgresql.conf
  3. select pg_relation_size(‘nazwa_relacji');
    zwraca wielkość w bajtach dowolnej relacji – czyli tabeli czy indeksu. warto w ten sposób monitorować tabele czy indeksy które mają tendencje do puchnięcia (np. jakieś tabele z kolejkami zadań do wykonania).
  4. select sum(xact_commit) + sum(xact_rollback) from pg_stat_database;
    zapytanie to pokazuje ile transakcji (od ostatniego restartu) postgres wykonał (niezależnie czy były one zatwierdzone czy wycofane).
    zbierając tę ilośc co jakiś czas można prosto policzyć ile transakcji na sekundę postgres robi. można też zmodyfikować to zapytanie tak aby liczyło nie transakcje w ogóle przetworzone przez postgresa tylko w konkretnej bazie – modyfikacje zapytania pozostawiam czytelnikom, jest trywialna 🙂

jeśli używacie replikacji, a do replikacji slony'ego, warto monitorować opóźnienie replikacyjne. opóźnienie to wyraża się w dwóch wartościach:

  • lag_events
  • lag_time

lag_events jest to ilość zdarzeń modyfikujących bazę które jeszcze nie zostały przesłane na bazy podrzędne. lag_time jest to opóźnienie replikacji wyrażone jako czas.

wyciągnąć obie wartości można tak:

SELECT st_lag_num_events, st_lag_time FROM _slony.sl_status

przy czym aby móc to rysować lepiej jest przeliczyć lag_time na sekundy:

SELECT st_lag_num_events, CAST(EXTRACT(epoch FROM st_lag_time) * 1000 AS int8) FROM _slony.sl_status

zwrócić należy uwagę, iż jeśli lag_events jest równy 0, to lag_time jest nieistotny.
na koniec coś na zewnątrz sql'a.

w postgresql.conf jest taka opcja: log_min_duration_statement.

pozwala ona na logowanie długich zapytań. tu uwaga – ta opcja jest zupełnie niezależna od log_statement czy log_duration!

wartość opcji log_min_duration_statement oznacza liczbę milisekund jaką zapytanie się wykonywało by zostało uznane za zbyt długie, i zalogowane. ja osobiście wpisuję tam wartość 1000 – co powoduje, że każde zapytanie trwające powyżej 1 sekundy zostanie zapisane w logach. np. tak:

[2006-07-14 00:00:04 CEST] [25989] LOG: duration: 1133.235 ms statement: SELECT id, file_extension FROM flickr_images WHERE id > ‘0' AND size_x_orig = 0 and status >= 0 ORDER BY id ASC

zwracam tylko uwagę na fakt iż to, że zapytanie się długo wykonuje niekoniecznie jest problemem zapytania. najczęstszą sytuacją jest to, że pojawia się zapytanie które wykonuje się dosyć długo, ale poza tym przyblokowuje ono zupełnie inne zapytania – które też nagle zaczynają się długo wykonywać.

tak więc nie należy patrzeć na listę “długich" zapytań jako listę rzeczy do poprawienia, tylko jako na wsad do analizy statystycznej jakie zapytania pojawiają się najczęściej. i dlaczego.

to zasadniczo kończy temat. monitorować można sporo więcej – choćby ilość procesów postgresa, czy ilości odczytów via seq-scan czy index-scan. natomiast wydaje mi się, że te parametry mogą być istotne “punktowo", a niekoniecznie muszą być od razu rysowane.

zdrowy rozsądek wygrał z kaso-chłonnymi prawnikami

kilka firm zaskarżyło ostatnio google'a, że zaniża ich pozycję w wynikach wyszukiwarki, przez co maleją im obroty.

nie znam tła historii (nie wiem czemu google obniżył im pageranka, ale nie uważam tego za przesadnie istotne).

istotne dla mnie jest to, że firma x chciała wywrzeć na firmie y modyfikację działania na korzystaniejsze dla x – mimo, że ich rynki się nie pokrywają, nie ma nieuczciwej konkurencji.

osobiście wychodzę z założenia, że indeksy google'a są własnością google'a i to co w nich robią jest ich prywatną sprawą.

interesujące było to jak do sprawy podejdzie system prawny stanów zjednoczonych. i okazało się, że zdrowy rozsądek wygrał. sędzie rozpatrujący sprawę ją oddalił.

komentarze po procesowe informują, że jednyną drogą jaką “pokrzywdzone" firmy mogą się walczyć o poprawienie wyników jest stwierdzenie, że obniżenie ich pageranka to zniesławienie. ale to będzie bardzo ciężko udowodnić.

przechowywanie historii zmian

zapewne każdy kto choć raz robił coś ciut większego w bazach danych zetknął się z problemem przechowywania zmian w tabelkach.

ci co nie mają triggerów, muszą używać kodu po stronie aplikacji klienckiej. co ma swoje wady.

z drugiej strony – w postgresie triggery są. i działają 🙂 więc można do roboty zaprząc postgresa.

załóżmy, że mamy bardzo prostą tabelkę z danymi:

CREATE TABLE objects (
id BIGSERIAL,
o_type TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id)
);
chcemy zapisywać sobie modyfikacje. robimy więc tabelkę na zapis historii zmian:
CREATE TABLE history_objects (
id BIGSERIAL,
modified_on TIMESTAMP NOT NULL DEFAULT now(),
modification_type TEXT NOT NULL DEFAULT '',
modified_id INT8,
modified_o_type TEXT,
PRIMARY KEY (id)
);

rekordy w history_object zawierają:

  • modified_on – kiedy dana modyfikacja miała miejsce
  • modification_type – typ modyfikacji – insert, update czy delete.
  • modified_id – id zmodyfikowanego rekordu
  • modified_o_type – o_type w tym rekordzie

proste i miłe. tworzymy więc dwa triggery:

CREATE OR REPLACE FUNCTION trg_objects_ui() RETURNS TRIGGER AS
$BODY$
DECLARE
BEGIN
INSERT INTO history_objects (modified_id, modified_o_type, modification_type) VALUES (NEW.id, NEW.o_type, TG_OP);
RETURN NEW;
END;
$BODY$
LANGUAGE 'plpgsql';
CREATE TRIGGER trg_objects_ui AFTER INSERT OR UPDATE ON objects FOR EACH ROW EXECUTE PROCEDURE trg_objects_ui();
 
CREATE OR REPLACE FUNCTION trg_objects_d() RETURNS TRIGGER AS
$BODY$
DECLARE
BEGIN
INSERT INTO history_objects (modified_id, modified_o_type, modification_type) VALUES (OLD.id, OLD.o_type, TG_OP);
RETURN NEW;
END;
$BODY$
LANGUAGE 'plpgsql';
CREATE TRIGGER trg_objects_d AFTER DELETE ON objects FOR EACH ROW EXECUTE PROCEDURE trg_objects_d();i testujemy:

testujemy:

> SELECT * FROM objects;
id | o_type
----+--------
(0 ROWS)
 
> SELECT * FROM history_objects;
id | modified_on | modification_type | modified_id | modified_o_type
----+-------------+-------------------+-------------+-----------------
(0 ROWS)
 
> INSERT INTO objects (o_type) VALUES ('bleble');
INSERT 0 1
 
> INSERT INTO objects (o_type) VALUES ('bleble2');
INSERT 0 1
 
> UPDATE objects SET o_type = 'xxx' WHERE o_type = 'bleble';
UPDATE 1
 
> DELETE FROM objects WHERE o_type = 'bleble2';
DELETE 1
 
> SELECT * FROM objects;
id | o_type
----+--------
1 | xxx
(1 ROW)
 
> SELECT * FROM history_objects;
id |        modified_on         | modification_type | modified_id | modified_o_type
----+----------------------------+-------------------+-------------+-----------------
1 | 2006-07-15 12:14:56.138695 | INSERT            |           1 | bleble
2 | 2006-07-15 12:14:56.138695 | INSERT            |           2 | bleble2
3 | 2006-07-15 12:14:56.138695 | UPDATE            |           1 | xxx
4 | 2006-07-15 12:14:56.138695 | DELETE            |           2 | bleble2
(4 ROWS)

działa ślicznie.

ale jest jedno ale.

czasem chcielibyśmy logować więcej informacji. w projekcie nad którym pracują koledzy w firmy chcieli logować nazwę akcji webowej (coś jakby url) i nazwę użytkownika które spowodowały taką modyfikację.

tu pojawiają się schody – baza danych nie zna urla. baza danych nie zna nazwy użytkownika który się zalogował na www – wie tylko jaki user jest zalogowany do bazy danych, ale to jest zazwyczaj zawsze jeden i ten sam user – niezależnie od tego na kogo się użytkownik loguje via www.

hmm. trzeba więc logować z poziomu aplikacji, a nie bazy.

no tak, ale jeśli user w jednej akcji powoduje modyfikacje wielu rekordów czy wielu tabel – sprawa zaczyna się komplikować.

na szczęście jest rozwiązanie.

wystarczy założyć dodatkową tabelkę do której będziemy dodawać nowy rekord przy rozpoczynaniu procedury obsługi każdego requestu, i będziemy tam wpisywać url'a i nazwę usera. a potem tylko baza musi umieć z tego skorzystać.

aby baza mogła powiązać dane z tej nowej tabelki (nazwijmy ją sobie “actions"), musi być w stanie jakoś powiązać późniejsze insert'y, update'y i delete'y z wpisami w actions. najlepiej to zrobić używając pidu (numer identyfikacyjny procesu) backendu postgresa.

w postgresie jest funkcja która zwraca ten pid:

SELECT pg_backend_pid();

dodajmy więc tabelkę actions, od razu z indeksem na to po czym będziemy szukać:

CREATE TABLE actions (
id BIGSERIAL,
backend_pid INT4 NOT NULL DEFAULT pg_backend_pid(),
action_on TIMESTAMP NOT NULL DEFAULT now(),
action_url TEXT,
action_user TEXT,
PRIMARY KEY (id)
);
CREATE INDEX ui_actions_bpao ON actions (backend_pid, action_on);

tabelkę history_objects modyfikujemy tak aby zawierała pola na urla i username (zamiast tego można dodać action_id, ale dodanie całych pól daje nam dodatkowe możliwości):

CREATE TABLE history_objects (
id BIGSERIAL,
modified_on TIMESTAMP NOT NULL DEFAULT now(),
modification_type TEXT NOT NULL DEFAULT '',
modification_url TEXT,
modification_user TEXT,
modified_id INT8,
modified_o_type TEXT,
PRIMARY KEY (id)
);

pozostało zmodyfikować triggery:

CREATE OR REPLACE FUNCTION trg_objects_ui() RETURNS TRIGGER AS
$BODY$
DECLARE
temprec actions%ROWTYPE;
BEGIN
SELECT * INTO temprec FROM actions WHERE backend_pid = pg_backend_pid() ORDER BY backend_pid DESC, action_on DESC LIMIT 1;
IF NOT FOUND THEN
temprec.action_url := 'unknown url. direct database access?';
temprec.action_user := 'database user: ' || CURRENT_USER;
END IF;
INSERT INTO history_objects (modified_id, modified_o_type, modification_type, modification_url, modification_user) VALUES (NEW.id, NEW.o_type, TG_OP, temprec.action_url, temprec.action_user);
RETURN NEW;
END;
$BODY$
LANGUAGE 'plpgsql';
CREATE TRIGGER trg_objects_ui AFTER INSERT OR UPDATE ON objects FOR EACH ROW EXECUTE PROCEDURE trg_objects_ui();
 
CREATE OR REPLACE FUNCTION trg_objects_d() RETURNS TRIGGER AS
$BODY$
DECLARE
temprec actions%ROWTYPE;
BEGIN
SELECT * INTO temprec FROM actions WHERE backend_pid = pg_backend_pid() ORDER BY backend_pid DESC, action_on DESC LIMIT 1;
IF NOT FOUND THEN
temprec.action_url := 'unknown url. direct database access?';
temprec.action_user := 'database user: ' || CURRENT_USER;
END IF;
INSERT INTO history_objects (modified_id, modified_o_type, modification_type, modification_url, modification_user) VALUES (OLD.id, OLD.o_type, TG_OP, temprec.action_url, temprec.action_user);
RETURN NEW;
END;
$BODY$
LANGUAGE 'plpgsql';
CREATE TRIGGER trg_objects_d AFTER DELETE ON objects FOR EACH ROW EXECUTE PROCEDURE trg_objects_d();

no to pora na test. najpierw zobaczymy jak to działa gdy nie zapiszemy nic do actions:

> SELECT * FROM objects;
id | o_type
----+--------
(0 ROWS)
 
> SELECT * FROM history_objects;
id | modified_on | modification_type | modification_url | modification_user | modified_id | modified_o_type
----+-------------+-------------------+------------------+-------------------+-------------+-----------------
(0 ROWS)
 
> SELECT * FROM actions;
id | backend_pid | action_on | action_url | action_user
----+-------------+-----------+------------+-------------
(0 ROWS)
 
> INSERT INTO objects (o_type) VALUES ('bleble');
INSERT 0 1
 
> INSERT INTO objects (o_type) VALUES ('bleble2');
INSERT 0 1
 
> UPDATE objects SET o_type = 'xxx' WHERE o_type = 'bleble';
UPDATE 1
 
> DELETE FROM objects WHERE o_type = 'bleble2';
DELETE 1
 
> SELECT * FROM objects;
id | o_type
----+--------
1 | xxx
(1 ROW)
 
> SELECT * FROM history_objects;
id |        modified_on         | modification_type |           modification_url           |   modification_user   | modified_id | modified_o_type
----+----------------------------+-------------------+--------------------------------------+-----------------------+-------------+-----------------
1 | 2006-07-15 12:58:54.16915  | INSERT            | UNKNOWN url. direct DATABASE access? | DATABASE USER: depesz |           1 | bleble
2 | 2006-07-15 12:58:58.46624  | INSERT            | UNKNOWN url. direct DATABASE access? | DATABASE USER: depesz |           2 | bleble2
3 | 2006-07-15 12:59:04.454578 | UPDATE            | UNKNOWN url. direct DATABASE access? | DATABASE USER: depesz |           1 | xxx
4 | 2006-07-15 12:59:09.519242 | DELETE            | UNKNOWN url. direct DATABASE access? | DATABASE USER: depesz |           2 | bleble2
(4 ROWS)

działa ładnie. to teraz zapiszmy coś do actions i powtórzmy test:

> INSERT INTO actions (action_url, action_user) VALUES ('/jakis_url.html', 'admin');
INSERT 0 1
 
> INSERT INTO objects (o_type) VALUES ('A:bleble');
INSERT 0 1
 
> INSERT INTO objects (o_type) VALUES ('A:bleble2');
INSERT 0 1
 
> UPDATE objects SET o_type = 'A:xxx' WHERE o_type = 'A:bleble';
UPDATE 1
 
> DELETE FROM objects WHERE o_type = 'A:bleble2';
DELETE 1
 
> SELECT * FROM objects;
id | o_type
----+--------
1 | xxx
3 | A:xxx
(2 ROWS)
 
> SELECT * FROM history_objects;
id |        modified_on         | modification_type |           modification_url           |   modification_user   | modified_id | modified_o_type
----+----------------------------+-------------------+--------------------------------------+-----------------------+-------------+-----------------
1 | 2006-07-15 12:58:54.16915  | INSERT            | UNKNOWN url. direct DATABASE access? | DATABASE USER: depesz |           1 | bleble
2 | 2006-07-15 12:58:58.46624  | INSERT            | UNKNOWN url. direct DATABASE access? | DATABASE USER: depesz |           2 | bleble2
3 | 2006-07-15 12:59:04.454578 | UPDATE            | UNKNOWN url. direct DATABASE access? | DATABASE USER: depesz |           1 | xxx
4 | 2006-07-15 12:59:09.519242 | DELETE            | UNKNOWN url. direct DATABASE access? | DATABASE USER: depesz |           2 | bleble2
5 | 2006-07-15 13:00:15.098039 | INSERT            | /jakis_url.html                      | admin                 |           3 | A:bleble
6 | 2006-07-15 13:00:18.315004 | INSERT            | /jakis_url.html                      | admin                 |           4 | A:bleble2
7 | 2006-07-15 13:00:21.304025 | UPDATE            | /jakis_url.html                      | admin                 |           3 | A:xxx
8 | 2006-07-15 13:00:24.483061 | DELETE            | /jakis_url.html                      | admin                 |           4 | A:bleble2
(8 ROWS)

działa. i to całkiem ładnie. oczywiście mechanizm można dalej rozszerzać. dodawać nowe pola (ip, browser) czy z części rezygnować.