#!/usr/bin/perl -w # Logfile analysis script to run the "Analog" logfile analysis tool. # Dean Pentcheff (dean2@biol.sc.edu). Copyright 1997 and 2000 by the # Trustees of the University of South Carolina. This program is free # software; you can redistribute it and/or modify it under the same # terms as Perl itself. # See the end of this file for changelog and documentation. # $Id: runanalog,v 1.18 2001/06/11 20:11:41 dean2 Exp $ use strict; # enforce strict interpretation of Perl syntax use Getopt::Std; # command-line option processing use CGI qw(:standard :html3); # just for generating HTML text, no CGI $CGI::Q = new CGI({}); # and terminate its CGI processing loop # full path to analog executable my($analog) = '/usr/bin/analog'; # parent directory into which HTML files are dumped my($htmldir) = '/home/apache/stats/'; # parent directory's URL (used only in the index page footer) my($htmldirurl) = 'http://stats.bsa171.org/'; # analog's built-in image directory URL (needs terminal slash) my($analogimageurl) = '/images/'; # filename of this-month-to-date file (within each server's output subdir) my($current) = 'current.html'; # title for the top-level index page itself (not the stats pages themselves) my($title) = 'Usage Statistics for Web Sites Hosted by BIERSTADT.BSA171.ORG'; # filename of the top-level HTML index file (within $htmldir) my($index) = 'index.html'; # html colors for top-level HTML index file my($pagecol) = 'white'; # index page background color my($oddcol) = '#ffcc99'; # index page odd data columns color my($evencol) = '#ffcc66'; # index page even data columns color my($headcol) = '#ff9966'; # index page header cell color my($nullcol) = '#cccccc'; # index page empty cell color my($ts) = `date`; chomp($ts); my($footer) = <<"EndOfFooter"; # index page ending boilerplate
Logfile analysis: Analog by Stephen Turner.
Original driver script: runanalog (man page) by Dean Pentcheff (dean2\@biol.sc.edu), Biological Sciences, University of South Carolina
Modified driver script: runanalog by Ryan Kirkpatrick (webmaster\@rkirkpat.net)
Last updated: $ts
EndOfFooter my(%defaults) = ( ### Probably invariant across servers (but could - override in %servers) # arguments for the monthly analog reports analogargs => [qw(-m +W +D +d -H +h -4 -5 +Sr-100 +Z +o +r +i +t +z), qw(+P +E +I +f +s +N +n +k +K +B +b +p -v +u +J +c), "-CREPORTORDER xmWDdHh45oZSkKfsNnBbpvirPEIuJztc1Qw67lLRMjYy", # phew "-CWARNINGS -R", # suppress null-report warnings "-CLASTSEVEN OFF", # no lastweek bits "-CGOTOS FEW", # hoplinks only at top & end "-CHOSTURL none", # no hostlink in title "-CSUBDIR /*/*/*", # hierarchical directory report "-CDIRCOLS NRb", # so give us an index column "-CSUBDOMAIN *.*", # hierarchical domain report "-CDOMCOLS NRb", # so give us an index column "-CPAGEINCLUDE *.cgi," . # supplement defined "pages" "*.php,*.php3,*.pdf,*.PDF,*.doc,*.DOC,*.txt,*.TXT", "-CDNS WRITE", # Enable DNS lookups. "-CDNSFILE /var/log/apache/dnsfile.txt", "-CDNSLOCKFILE /var/log/apache/dnslock", ], # path to the www server's log directory logdir => '/var/log/apache/', ### Per-server information - override in %servers on a per-server basis # display name of the server hosttitle => '', # real name of the server for making links on stats pages hostname => '', # rootname for the logfiles within the logdir directory logname => '', # directory name for HTML output within the htmldir usage directory hostdir => '', # placeholder for additional analog args added in the %servers hash analogextra => [], # link target for use in the HTML index page's host column title tablelink => '', ); my(%servers,@serverOrder); @serverOrder = ( "www", "ssl", "ssls", "db", "mail"); %servers = ( mail => {hosttitle => 'mail.b.o', hostname => 'mail.bsa171.org', logname => 'mail-access.log', hostdir => 'mail', tablelink => 'http://mail.bsa171.org/', }, ssl => {hosttitle => 'ssl.b.o', hostname => 'ssl.bsa171.org', logname => 'ssl-http-access.log', hostdir => 'ssl', tablelink => 'http://ssl.bsa171.org/', }, ssls => {hosttitle => 'ssl.b.o
(SSL)', hostname => 'ssl.bsa171.org', logname => 'ssl-https-access.log', hostdir => 'ssls', tablelink => 'https://ssl.bsa171.org/', }, www => {hosttitle => 'www.bsa171.org', hostname => 'www.bsa171.org', logname => 'www-access.log', hostdir => 'www', tablelink => 'http://www.bsa171.org/', }, db => {hosttitle => 'db.b.o', hostname => 'db.bsa171.org', logname => 'db-access.log', hostdir => 'db', tablelink => 'http://db.bsa171.org/', } ); sub help { print STDERR < 1970") unless ($year > 1970); die("Specified month must be between 1 and 12 inclusive") unless ($mon >= 1 && $mon <= 12); } # do we actually want to summarize last month? my($firstday) = (($mday == 1 && ! $forcefirst) || ($mday != 1 && $forcefirst)); if ($firstday) { if (--$mon < 1) { --$year; # we were in Jan, so set for Dec of last year $mon = 12; } } my($yy) = substr($year, 2, 2); # get a two-digit year for analog's use printf STDERR "Analysis month is: %02d, %4d\n", $mon, $year if ($verbose); # shortcut here if we're just (re)making the index file if ($indexonly) { print STDERR "just making indexfile $htmldir/$index\n" if ($verbose); make_index($title, $htmldir, $index, $current, \@serverOrder, \%servers); exit 0; } # now step through each of the desired servers my @order; foreach (@serverOrder) { /$serverre/ and push(@order,$_); } @serverOrder = @order; print STDERR "Remaining servers: ", join(" ", keys %servers), "\n" if $verbose; foreach (@serverOrder) { # merge the per-server config with the default/generic config my($servertag) = $_; my(%cnf) = %defaults; for (keys %{$servers{$servertag}}) { if (exists $cnf{$_}) { $cnf{$_} = $servers{$servertag}->{$_}; } else { print STDERR "Warning: server '$servertag', bogus config: '$_'\n"; } } # get list of access_log files, in lexicographic == time-ascending order opendir(DIR, $cnf{logdir}) || die("Failed to open $cnf{logdir}: $!"); my(@logfiles) = map { "$cnf{logdir}/$_" } sort {$b cmp $a} grep(/^$cnf{logname}(|\.\d+)$/, readdir(DIR)); closedir(DIR); @logfiles = grep( ! /\.[234]/, @logfiles) if $debug; print STDERR "Log files are:\n\t", join("\n\t", @logfiles), "\n" if ($verbose); # make sure we've got an output directory, or make one die("Missing output directory!: $htmldir") unless -d $htmldir; unless (-d "$htmldir/$cnf{hostdir}") { mkdir "$htmldir/$cnf{hostdir}", 0755 or die("Failed mkdir '$htmldir/$cnf{hostdir}': $!"); } # blank out the "current" file if we're on the first of the month empty_current("$htmldir/$cnf{hostdir}/$current") if ($firstday); # get our analog run timing and outfile args ready my($from, $to, $out); $from = sprintf("+F%02d%02d01", $yy, $mon); if ($setdate || $firstday) { $to = sprintf("+T%02d%02d31", $yy, $mon); # 31 is ok for all months $out = sprintf("$htmldir/$cnf{hostdir}/%4d%02d.html", $year, $mon); if (-e $out && ! $overwrite) { print STDERR "Will not overwrite existing '$out'\n"; next; } } else { $to = sprintf("+T%02d%02d-01", $yy, $mon); # through yesterday $out = "$htmldir/$cnf{hostdir}/$current"; } # get an HTML-cleaned version of the hostname for title in page my($title) = $cnf{hosttitle}; $title =~ s/<.*?>/ /g; # do the damned run already, fer crissake my($rc) = 0; # analog run return code my(@cmd) = ($analog, @{$cnf{analogargs}}, $from, $to, "-COUTFILE $out", # output file "-CIMAGEDIR $analogimageurl", # URL for analog's images "-CHOSTNAME \"$title\"", # for title displays "-CBASEURL http://$cnf{hostname}", # for internal links @{$cnf{analogextra}}, # additional analog args map { "-CLOGFILE $_" } @logfiles ); printf STDERR "@cmd\n" if ($verbose); unless ($fake) { $rc = 0xffff & system(@cmd); unless ($rc == 0) { $rc = sprintf("%#04x", $rc); warn("$analog returned code $rc: $!"); } } } # find all historic and currentfiles and (re)create toplevel HTML page make_index($title, $htmldir, $index, $current, \@serverOrder, \%servers); print STDERR "Done.\n" if ($verbose); exit 0; # Generate an empty "none here yet" version of the HTML currentfile sub empty_current { my($file) = @_; print STDERR "emptying 'current' file '$file'\n" if ($verbose); return if $fake; open(OUT, "> $file") || die("Can't open current-stats file '$file': $!\n"); print OUT start_html(-title => 'Month-to-Date Log Statistics', -bgcolor => $pagecol), h1('Month-to-Date Log Statistics'), h2('This Month to Date'), p(), i('No month-to-date statistics yet for this month. ' . 'See historical listings for statistics up to now.'), end_html(); close OUT; } # Generate the HTML index page linking to historical and currentfiles sub make_index { my($title, $dir, $index, $current, $order, $list) = @_; my(@months) = ('', # months are based on Jan=1, so have an empty 0th qw(January February March April May June July August September October November December)); my(%files); print STDERR "making indexfile $htmldir/$index\n" if ($verbose); # mine all files, note oldest/newest, and pluck some stats my($oldest) = '999999'; my($newest) = '000000'; foreach (@$order) { my($servertag) = $_; my($hostdir) = $list->{$servertag}->{hostdir}; opendir(DIR, "$dir/$hostdir") or die("Failed to open directory '$dir/$hostdir': $!"); my(@histfiles) = sort {$b cmp $a} grep(/^(\d{6}\.html|$current)$/, readdir(DIR)); closedir(DIR); print STDERR "in $dir/$hostdir:\n\t", join("\n\t", @histfiles), "\n" if $verbose; # assemble a hash, keyed by yyyymm or 'current', with stats line for (@histfiles) { my($file) = "$dir/$hostdir/$_"; my($filetag); if (/^(\d{6})/) { $filetag = $1; $oldest = $filetag if $filetag < $oldest; $newest = $filetag if $filetag > $newest; } elsif (/^$current/) { $filetag = $current; } else { die("Program error - should be either dated or current"); } # grub my($reqs) = ''; my($bytes) = ''; open(FILE, $file) or die("Failed to open '$file' for reading: $!"); while () { $reqs = $2 if (/(Total s|S)uccessful requests:[^\d]*(.*)/); $bytes = $2 if (/(Total d|D)ata transferred:[^\d]*(.*)/); last if ($reqs && $bytes); } close FILE; foreach ($reqs, $bytes) { next unless $_; my($mult) = 1; $mult = 2**10 if (/kbyte/i || /kilobytes/i); $mult = 2**20 if (/mbyte/i || /megabytes/i); $mult = 2**30 if (/gbyte/i || /gigabytes/i); s/[^0-9\.]//g; $_ *= $mult; } $bytes = int($bytes / 1048576 + 0.5) if ($bytes); # force to MB 1 while $bytes =~ s/^(-?\d+)(\d{3})/$1,$2/; # commify 1 while $reqs =~ s/^(-?\d+)(\d{3})/$1,$2/; # commify if ($reqs) { # allow 0 $bytes, since < 0.5MB goes to 0 $files{$servertag}{$filetag} = [$reqs, $bytes]; } else { # suppress indexing this file if there's nothing in it delete $files{$servertag}{$filetag}; } } } print STDERR "Oldest = $oldest Newest = $newest\n" if $verbose; return if $fake; # massage things into an output table open(IDX, "> $dir/$index") || die("Failed to open $dir/$index: $!"); print IDX start_html(-title => $title, -bgcolor => $pagecol, -meta => {"robots", "noindex,nofollow", }, ), qq|

$title

|, qq|\n|, qq||; foreach (@$order) { print IDX qq||; } print IDX qq|\n|; foreach (@$order) { print IDX qq||, qq||; } print IDX "\n"; # the 'current' entries, first data row, suppressed on first of the month my($anycurrents) = 0; # first, are there any current entries? foreach (@$order) { ++$anycurrents if exists $files{$_}->{$current}; } # if so, punch out a line for them if ($anycurrents) { print IDX qq||; foreach (@$order) { if (exists $files{$_}->{$current}) { my($reqs, $bytes) = @{$files{$_}->{$current}}; print IDX qq||, qq||; } else { print IDX qq||, qq||; } } print IDX "\n"; } # now fill in all available historical months my($newestyear, $newestmonth) = $newest =~ /^(\d{4})(\d{2})/; my($oldestyear, $oldestmonth) = $oldest =~ /^(\d{4})(\d{2})/; my($year) = $newestyear; my($month) = $newestmonth; while ($year > $oldestyear || ($year == $oldestyear && $month >= $oldestmonth) ) { my($yyyymm) = sprintf "%04d%02d", $year, $month; print IDX qq||; foreach (@$order) { if (exists $files{$_}->{$yyyymm}) { my($reqs, $bytes) = @{$files{$_}->{$yyyymm}}; $reqs = ' ' unless $reqs ne ''; $bytes = ' ' unless $bytes ne ''; print IDX qq||, qq||; } else { print IDX qq||, qq||; } } print IDX "\n"; if (--$month < 1) { $month = 12; --$year; } } print IDX "
 |; print IDX qq|| if exists $list->{$_}->{tablelink}; print IDX $list->{$_}->{hosttitle}; print IDX qq|| if exists $list->{$_}->{tablelink}; print IDX qq|
 |, qq|Requests|, qq|MB
Month-to-Date|, qq||, qq|$reqs|, qq|$bytes |, qq| 
|, qq|$year $months[$month]{$_}->{hostdir}/$yyyymm.html\">|, qq|$reqs|, qq|$bytes |, qq| 
", $footer, end_html; close IDX; } __END__ # $Log: runanalog,v $ # Revision 1.18 2001/06/11 20:11:41 dean2 # Added check to put in a   in the case of empty requests # or bytes in the top-level index page. Needed to add the old # tbone logfiles, which lack bytecounts. # # Revision 1.17 2001/02/02 05:44:15 dean2 # Did some aliasing and argsexcluding on the www-s server. # Added a space when purging HTML from host titles. # Fixed up the "-h" help strings. # # Revision 1.16 2001/02/02 02:18:22 dean2 # Fixed a missing ">" in the current-file link in the toplevel index file. # Added function to the "-f" switch so that it turns _off_ # first-of-the-month processing on the first of the month. # Updated documentation to fit. # # Revision 1.15 2001/01/26 17:39:25 dean2 # Changed column titles slightly. # # Revision 1.14 2001/01/26 04:03:21 dean2 # Added the USC-only and nonUSC-only breakdowns for www. # Shuffled host order yet again. # # Revision 1.13 2001/01/26 02:15:37 dean2 # Shuffled the server order. # # Revision 1.12 2001/01/26 02:09:52 dean2 # Moved gator from remote to local logfile location. # Left hughes there (though it is now dead) to document lack of use to date. # # Revision 1.11 2001/01/23 09:27:44 dean2 # Redid the output display to better visually separate # hit counts from byte counts in the index table. # # Revision 1.10 2001/01/23 08:46:13 dean2 # Added the "tablelink" item to the server hash, so that we could put up # links to the Netcraft server uptime graphs. # Added marine.geol.sc.edu to the lineup. # # Revision 1.9 2001/01/15 22:51:45 dean2 # Added darwin and darwin-proxy to the lineup. # # Revision 1.8 2001/01/11 17:57:42 dean2 # Moved zebra's log location from the "remote" directory to the normal one. # # Revision 1.7 2000/08/10 17:16:13 dean2 # Replaced space between year and month with a non-break space. # # Revision 1.6 2000/08/07 17:56:29 dean2 # Minor doc changes. # # Revision 1.5 2000/08/06 03:46:39 dean2 # Made some reports hierarchical. # Tweaked output format and report order. # # Revision 1.4 2000/08/05 03:04:04 dean2 # Changed some file locations. # Modified arglist to use -CLOGFILE instead of a list of filenames; # analog seemed unhappy with a long comma-separated list. # # Revision 1.3 2000/08/04 23:05:32 dean2 # Patched docs, tweaked HTML. # # Revision 1.2 2000/08/04 21:46:24 dean2 # First tentative production version. # Wrote pod documentation. # # Revision 1.1 2000/08/04 09:02:31 dean2 # Initial revision # # startopod =pod =head1 NAME runanalog - wrapper to run the Analog webfile analysis program =head1 SYNOPSIS runanalog [arguments] =head1 DESCRIPTION The Analog web log analyzer needs to be run on a periodic basis with specified arguments to generate any sort of sequential log files. This program, run daily by L or another suitable scheduler, will call Analog appropriately. This program does I rotate log files or schedule its own invocation. Each server gets its own subdirectory for Analog output files, and each month's summary file is named using the date (I). In the parent directory is an index page consisting of an HTML table with one row per month and a double column for each server analyzed. Each row for each server shows the total successful accesses and MB transferred (harvested from the Analog analysis files). Each of those numbers is a link to the full Analog analysis file for that server for that month. It assumes the following: =over A server's logfiles are in a single directory A month's worth of logfiles can remain in that directory through the first of the subsequent month. The logfiles are small enough that it's OK to reread them all each day when this program is run. (Analog is very fast, so this is probably less of an issue than it sounds.) Multiple logfile generations accumulate with the following naming pattern in increasing age (the logfile base name is configurable): F F F ... =back =head2 Commandline arguments =over =item -x Just make the top-level index file based on existing Analog output files. Don't read logfiles or run Analog itself. =item -o Force overwriting of existing Analog output files. By default, if a monthly summary already exists, it will not be overwritten (and a warning message is sent to STDERR). I however that the "current" (this-month-to-date) file can always be overwritten. =item -w servername Process only F's log entries. F can be any Perl regular expression, and is matched against the server name keys in the I<%servers> hash (see configuration details). Hence it can select more than one server's logs for processing. (Note that if you include special characters like "|" or "*" in the regular expression, you will probably have to enclose this argument in single quotes to protect it from expansion by your commandline shell.) =item -s YYYYMM Force our idea of the "current date" to YYYYMM. It must be a four-digit year followed by a two-digit month (Jan=01, Dec=12). =item -f On any day execept the first of the month, force "first of the month" processing: produce a complete summary of I month's statistics, and generate an empty "current statistics" file (since we only ever summarize through the preceeding midnight, there are no month-to-date numbers for the first day of each month). If it really is the first day of the month, then B<-f> has the opposite effect: forces the program to ignore the fact that we are running it on the first of the month. Trying to process older data using the B<-s> flag on the first of the month will cause off-by-one-month results if you don't supply the B<-f> flag as well. =item -v Run verbosely, telling us what's happening. =item -n Fake the run, avoid actually writing anything or running Analog itself. (Probably you want B<-v> as well.) =item -h Print a little command-line argument summary and quit. =item -d Turn on a debugging flag, which may do something the programmer was interested the last time he tweaked the code -- check the code for what this does. =back =head2 Configuration B is intended to be run nightly by L or a similar scheduling program. In normal use it needs no commandline arguments. Check your system docs to see how to run a program periodically using L (or similar). It's probably best to run this early in the morning so that the 'current' summary covers through yesterday. Configuration is done by setting some variables in the program text. There are three main sections. (I know this could be done using an external config file, but I really couldn't be bothered.) Starting at the beginning are some global scalars. Set them appropriately to point to your program locations, directories, etc. Note that I<$htmldir> is a parent directory for the analysis output: it will contain an overall index file and one subdirectory for each server's file analyzed. Comments preceeding each describe their purpose. Next is the I<%defaults> hash. These values are set as the default for all servers, and then server-specific values (where needed) are specified (see next paragraph). The main things to check here are the Analog argument string (I) and the full path to the server log files (I). Leave the per-server entries empty. Last is the I<%servers> hash. This has one clause for each server's files you wish to analyze. The columns in the overall index file will be in the same order as the items initializing this hash. Here's one that will run analysis on two servers: tie(%servers, 'Tie::IxHash', www => {hosttitle => 'www', hostname => 'www.biol.sc.edu', logname => 'www.access_log', hostdir => 'www', }, mail => {hosttitle => 'mail host', hostname => 'mail.biol.sc.edu', logname => 'mail.access_log', hostdir => 'mail', analogextra => ['-CALLGRAPH R', '-CRAWBYTES ON'], tablelink => 'http://www.netcraft.com/whats?host=mail.biol.sc.edu', }, }; The first server, identified by the key C, is probably the most simple setup you'll have. The server's name for page titles will be I, the hostname for use in web links within Analog's reports will be C, the logfiles all begin with F, and are found in the I directory noted in the I<%defaults> hash (if that were not true, a I entry could be put into I's clause here). This server's Analog output files will be put in a subdirectory named "www" (the I value) within the I<$htmldir> directory configured in the scalar variables above. The next entry is similar, but adds the I item to tack on a few additional Analog command-line variables. Such variables should be in the form of an anonymous array, one array item per argument. It also adds the I item which can hold a link target for the host's column header in the indexing HTML page that will be generated. =head1 EXAMPLES =over =item Daily run by cron(8) in the early morning: runanalog No arguments at all cause it to parse all logfiles for all servers in the I<%servers> hash, summarizing the stats for this month to date into the 'current' file in each server's output directory, and recreate the overall index file table in I<$htmldir>. If and only if it is the first day of the month, the preceeding month's statistics will be generated into a file (named YYYYMM.html) in each server's output directory and their 'current' files will be effectively emptied (since we only ever summarize through the preceeding midnight, there are no month-to-date numbers for the first day of each month). =item Initial run with existing logfiles covering several months: mkdir /usr/local/apache/htdocs/usage runanalog -s 199912 runanalog -s 200001 runanalog -s 200002 runanalog You've just compiled Analog, grabbed this program, configured it, and you'd like to catch up on your log file summaries. This example assumes that the date today is in the middle of March, 2000 and you've got logfiles running back through December, 1999. First create your output directory (a subdirectory for each server will be automatically created in that directory). Then do a run for each full month preceeding the current month. Finally, do a run to summarize the current month to date. (Note that if you happened to be doing this on the first day of a month, you'd want to use the B<-f> flag along with the B<-s> flags to keep the program from doing "first of the month" processing.) =item Play with index page colors or formatting: runanalog -x This will just read the existing Analog output files and regenerate the I<$index> page, without reading any web log files or running Analog. =item Fix recent summaries, or you don't run runanalog every day: runanalog -f runanalog Something weird happened last month or early this month, so you'd like to update last month's summaries and bring things up to date. The first line pretends to be running on the first of the month to summarize last month (into a date-named file), then the second line summarizes the current month to date. If you're not running I every day, then the first time you run it each month you'll want to do both the B<-f> run to get the previous month neatly packaged off, then the regular argumentless run to summarize the current month to date. (Note that if it just happens to be the first of the month when you run this, then you won't need the B<-f> flag.) =item Limit the run to only two servers: runanalog -w 'www|stkctr' -s 200002 runanalog -w 'www|stkctr' runanalog -x The first line will summarize February 2000 logs for servers matching either "www" or "stkctr" as their I<%servers> hash key, and regenerate the top-level index file including only those two servers. The second line will do a month-to-date run for just those servers, and again regenerate the index file. The third line would regenerate the top-level index file for all the servers after you finished selective runs. This is an efficient sequence to introduce a new server or servers to the summary system without reanalyzing the older logs for the existing servers. (Note that if you happened to be doing this on the first day of a month, you'd want to use the B<-f> flag along with the B<-s> flag to keep the program from doing "first of the month" processing.) =back =head1 BUGS No doubt. =head1 FILES (Referred to by their scalar variable names in the program.) =over =item $analog Path to the Analog executable program. =item $htmldir Path for the parent directory into which all the Analog analyses and the overall index file are placed. Each server's analyses get a subdirectory within this directory. =item $analogimageurl Analog comes with a directory of small images it uses to build up graphs (and its icon). This should be the URL of the directory containing those graphics for use in Analog's output pages. =item $current The filename used for the 'current' or month-to-date summary in each server's directory, initially set to F. It's probably best I to use F for this, or people will be prevented from browsing the directory for prior analyses. =item $index The filename used for the overall summary index file in the I<$htmldir> directory. Initially set to F. =back =head1 AUTHOR Dean Pentcheff (dean2@biol.sc.edu). Copyright 1997 and 2000 by the Trustees of the University of South Carolina. This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =head1 SEE ALSO Analog logfile analysis program from http://www.statslab.cam.ac.uk/~sret1/analog/ Perl module L for hashes with ordered keys. Perl module L for HTML formatting. =cut # endopod ############ end of file ############