Attachment 'jmetergraphhtml.pl'

Download

   1 #!/usr/bin/perl
   2 #---
   3 # Original script by Christoph Meissner (email unknown)
   4 # Taken from http://wiki.apache.org/jakarta-jmeter-data/attachments/LogAnalysis/attachments/jmetergraph.pl
   5 # Fixes by George Barnett (george@alink.co.za)
   6 # Small patch (from mhardy@tkdevvm) and html output added by Aaron Forster (jmeter@forstersfreehold.com)
   7 #
   8 # Fixes:
   9 # - Modified to 'use strict'
  10 # - Fixed scoping of various variables
  11 # - Added DEBUG print option
  12 #
  13 # Script Options:
  14 # perl jmetergraph.pl  [-alllb] [-stddev] [-range] <jtl file 1> [ ... <jtl file n>]
  15 # -alllb [Script does not draw stacked chart for requests that are < 1% of total.  This disables this behaviour]
  16 # -stddev [Will draw the std dev]
  17 # -range [Wil draw request range]
  18 #
  19 #---
  20 #
  21 # This script parses xml jtl files created by jmeter.
  22 # It extracts timestamps, active threads and labels from it.
  23 #
  24 # Further, based on this data
  25 # it builds below chart files (see sample files):
  26 #
  27 # 1. one chart containing the overall response times related to active threads.
  28 #    The resulting png file is named 'entire_user.png'.
  29 #
  30 # 2. one chart containing the overall response times related to throughput
  31 #    The resulting png file is named 'entire_throughput.png'.
  32 #
  33 # 3. one chart that will contain bars for each label
  34 #    which were stacked over response intervals (expressed in msec)
  35 #    The resulting png file is named 'ChartStacked.png'
  36 #    
  37 # 4. one chart that will contain stacked bars for each label
  38 #    which were stacked over response intervals (expressed in msec)
  39 #    The resulting png file is named 'ChartStackedPct.png'
  40 #
  41 # 5. one chart per label containing response times related to active threads and throughput
  42 #    This chart png is named like the label itself with the non-word characters substituted by '_'
  43 #    If it is related to active threads then '_user' is appended to the graph's file name -
  44 #    otherwise a '_throughput'.
  45 #
  46 # IMPORTANT:
  47 # ==========
  48 # The script works best with jmeter log format V2.1.
  49 # I never tested it on log's of either older or newer versions.
  50 # Also, I was too sluggish to include an XML parser.
  51 # Thus the script parses the jtl files by using regex.
  52 #
  53 # The graphs are built depending on the names of the labels of your requests.
  54 # Thus group your label names with this in mind
  55 # when you are about to create your jmeter testplan,
  56 #
  57 # Also, only the labels on the 1st level are considered.
  58 # They accumulate the response time, active users and throughput 
  59 # for all sub levels.
  60 #
  61 #
  62 # FAQ:
  63 # ====
  64 # How to make this script to create proper charts?
  65 #
  66 # Open your testplan with jmeter and
  67 # 1. insert listener 'Aggregate Report' (unless not present already).
  68 # 2. invoke 'Configure' there
  69 # 3. make sure that below checks are activated at least:
  70 #    'Save as XML'
  71 #    'Save Label'
  72 #    'Save Time Stamp'
  73 #    'Save Thread Name'
  74 #    'Save Active Thread Counts'
  75 #    'Save Elapsed Time'
  76 # 4. tell 'Aggregate Report' to write all data into a file
  77 # 5. when building your testplan
  78 #    you should name your critical request samplers in a way that data can be grouped into it
  79 #    (eg. 'Login', 'Host request', 'continue page', 'Req Database xyz', ...)
  80 #
  81 # Perl requisites:
  82 # 6. install the perl 'GD' package
  83 # 7. install the perl 'Chart' package
  84 # 8. test perl.
  85 #    This means that
  86 #     perl -MGD -MChart::Lines -MChart::Composite -MChart::StackedBars
  87 #    shouldn't fail
  88 #
  89 # To run the script:
  90 # perl jmetergraph.pl <jtl file1> <jtl file2> ... <jtl file_n>
  91 # 
  92 # The png graphs will be created in current working directory
  93 # 
  94 # I created this checklist to my best knowledge.
  95 # However, if it fails in your computer environment, ...
  96 # 
  97 #---
  98 
  99 use strict;
 100 use Chart::StackedBars;
 101 use Chart::Composite;
 102 use Chart::Lines;
 103 
 104 #---
 105 # arguments passed?
 106 #---
 107 my @files = grep {!/^-/ && -s "$_"} @ARGV;
 108 our @args = grep {/^-/ && !-f "$_"} @ARGV;
 109 
 110 my $DEBUG = 1;
 111 my $INDEXFILE; # Filehandle
 112 
 113 my %entire = ();
 114 my %glabels = ();
 115 my %gthreads = ();
 116 my $atflag = 0; # if active threads found then according graphs will be created
 117 #---
 118 # data received within this intervall will be averaged into one result
 119 #---
 120 
 121 my $collectinterval = 180;    # 60 seconds
 122 #---
 123 # cusps aggregate response times
 124 #---
 125 our @cusps = (200, 500, 1000, 2000, 5000, 10000, 60000);
 126 
 127 #---
 128 # labels determine the name of output charts
 129 #---
 130 our @labels = ();
 131 our @threads = ();
 132 
 133 #---
 134 # intermediate values
 135 #---
 136 our %timestamps = ();
 137 our $respcount = 0;
 138 our $measures = 0;
 139 
 140 my ($entireta,$entirecnt,$entireby);
 141 my ($respcount,$sumresptimes,$sumSQresptimes); 
 142 
 143 #---
 144 # define colors for stacked bar charts
 145 #---
 146 our %colors = (
 147   dataset0 => "green",
 148   dataset1 => [0, 139, 139], # dark cyan
 149   dataset2 => [255, 215,0],  # gold
 150   dataset3 => "DarkOrange",
 151   dataset4 => "red",
 152   dataset5 => [255, 0, 0],   # red
 153   dataset6 => [139, 0, 139], # dark magenta
 154   dataset7 => [0, 0, 0],     # black
 155 );
 156 
 157 #---
 158 # Create an html index file
 159 #---
 160 open ($INDEXFILE, '>>index.html');
 161 
 162 #---
 163 # here we go thru all files and collect data
 164 #---
 165 
 166 while(my $file = shift(@files)) {
 167   print "Opening file $file\n" if $DEBUG;
 168   open(IN, "<$file") || do  {
 169     print $file, " ", $!, "\n";
 170     next;
 171   };
 172 
 173   print "Parsing data from $file\n" if $DEBUG;
 174   while(<IN>) {
 175     my ($time,$timestamp,$success,$label,$thread,$latency,$bytes,$DataEncoding,$DataType,$ErrorCount,$Hostname,$NumberOfActiveThreadsAll,$NumberOfActiveThreadsGroup,$ResponseCode,$ResponseMessage,$SampleCount);
 176     if(/^<(sample|httpSample)\s/) {
 177 
 178       ($time) = (/\st="(\d+)"/o);
 179       ($timestamp) = (/\sts="(\d+)"/o);
 180       ($success) = (/\ss="(.+?)"/o);
 181       ($label) = (/\slb="(.+?)"/o);
 182       ($thread) = (/\stn="(.+?)"/o);
 183       ($latency) = (/\slt="(\d+)"/o);
 184       ($bytes) = (/\sby="(\d+)"/o);
 185       ($DataEncoding) = (/\sde="(\d+)"/o);
 186       ($DataType) = (/\sdt="(.+?)"/o);
 187       ($ErrorCount) = (/\sec="(\d+)"/o);
 188       ($Hostname) = (/\shn="(.+?)"/o);
 189       ($NumberOfActiveThreadsAll) = (/\sna="(\d+)"/o);
 190       ($NumberOfActiveThreadsGroup) = (/\sng="(\d+)"/o);
 191       ($ResponseCode) = (/\src="(.+?)"/o);
 192       ($ResponseMessage) = (/\srm="(.+?)"/o);
 193       ($SampleCount) = (/\ssc="(\d+)"/o);
 194 
 195     } elsif(/^<sampleResult/) {
 196       ($time) = (/\stime="(\d+)"/o);
 197       ($timestamp) = (/timeStamp="(\d+)"/o);
 198       ($success) = (/success="(.+?)"/o);
 199       ($label) = (/label="(.+?)"/o);
 200       ($thread) = (/threadName="(.+?)"/o);
 201     } else {
 202       next;
 203     }
 204 
 205     $label =~ s/\s+$//g;
 206     $label =~ s/^\s+//g;
 207     $label =~ s/[\W\s]+/_/g;
 208 
 209     next if($label =~ /^garbage/i); # don't count these labels into statistics
 210 
 211     #---
 212     # memorize labels
 213     #---
 214           if(!grep(/^$label$/, @labels)) {
 215       push(@labels, $label);
 216       print "Found new label: $label\n" if $DEBUG;
 217     }
 218     $glabels{$label}{'respcount'} += 1;
 219     $entire{'respcount'} += 1;
 220 
 221     #---
 222     # memorize timestamps
 223     #---
 224 
 225     my $tstmp = int($timestamp / (1000 * $collectinterval)) * $collectinterval;
 226     $timestamps{$tstmp} += 1;
 227 
 228     #---
 229     # cusps
 230     #---
 231     for(my $i = 0; $i <= $#cusps; $i++) {
 232       if(($time <= $cusps[$i]) || (($i == $#cusps) && ($time > $cusps[$i]))) {
 233         $glabels{$label}{$cusps[$i]} += 1;
 234         $entire{$cusps[$i]} += 1;
 235         last;
 236       }
 237     }
 238     #---
 239     # stddev
 240     #---
 241     $respcount += 1;
 242     $sumresptimes += $time;
 243     $sumSQresptimes += ($time ** 2);
 244     if($respcount > 1) {
 245       my $stddev = sqrt(($respcount * $sumSQresptimes - $sumresptimes ** 2) /
 246         ($respcount * ($respcount - 1)));
 247 
 248       $entire{$tstmp, 'stddev'} = $glabels{$label}{$tstmp, 'stddev'} = $stddev;
 249 
 250     }
 251 
 252     #---
 253     # avg
 254     #---
 255     $entire{$tstmp, 'avg'} = $sumresptimes / $respcount;
 256 
 257     $glabels{$label}{$tstmp, 'responsetime'} += $time;
 258     $glabels{$label}{$tstmp, 'respcount'} += 1;
 259     $glabels{$label}{$tstmp, 'avg'} = int($glabels{$label}{$tstmp, 'responsetime'} / $glabels{$label}{$tstmp, 'respcount'});
 260 
 261     #---
 262     # active threads
 263     #---
 264 
 265     if(!$entire{$tstmp, 'activethreads'}) {
 266       $entireta = 0;
 267       $entirecnt = 0;
 268       $entireby = 0;
 269     }
 270 
 271     if($NumberOfActiveThreadsAll > 0) {
 272       $atflag = 1;
 273     }
 274 
 275     $entirecnt += 1;
 276 
 277     if($atflag == 1) {
 278       $entireta += $NumberOfActiveThreadsAll;
 279       $entire{$tstmp, 'activethreads'} = int($entireta / $entirecnt);
 280   
 281       if(!$glabels{$label}{$tstmp, 'activethreads'}) {
 282         $glabels{$label}{$tstmp, 'lbta'} = 0;
 283         $glabels{$label}{$tstmp, 'lbby'} = 0;
 284       }
 285       $glabels{$label}{$tstmp, 'lbta'} += $NumberOfActiveThreadsAll;
 286       $glabels{$label}{$tstmp, 'activethreads'} = sprintf("%.0f", $glabels{$label}{$tstmp, 'lbta'} / $glabels{$label}{$tstmp, 'respcount'});
 287 
 288     } else {
 289       #---
 290       # if NumberOfActiveThreads is not available
 291       # use threadname to extrapolate active threads later
 292       #---
 293       if($NumberOfActiveThreadsAll eq '') {
 294               if(!$gthreads{$thread}{'first'}) {
 295           $gthreads{$thread}{'first'} = $tstmp;
 296           push(@threads, $thread);
 297         }
 298   
 299         $gthreads{$thread}{'last'} = $tstmp;
 300       }
 301     }
 302 
 303     #---
 304     # throughput
 305     #---
 306     if($bytes > 0) {
 307       $entireby += $bytes;
 308       $entire{$tstmp, 'throughput'} = int($entireby / $entirecnt);
 309   
 310       $glabels{$label}{$tstmp, 'lbby'} += $bytes;
 311       $glabels{$label}{$tstmp, 'throughput'} = $glabels{$label}{$tstmp, 'lbby'}; # counts per $collectinterval
 312 	  $glabels{'entire'} = \%entire;
 313     }
 314 
 315   }
 316   print "Closing $file\n" if $DEBUG;
 317   close(IN);
 318 }
 319 
 320 print "Found $#labels labels\n" if $DEBUG;
 321 
 322 # Sort the labels.
 323 print "Sorting labels\n" if $DEBUG;
 324 my @tmplabels = sort @labels;
 325 @labels = @tmplabels;
 326 
 327 #---
 328 # if required (no NumbersOfActiveThreads)
 329 # then extrapolate users
 330 #---
 331 if($atflag == 0) {
 332   print "using timestamps to calculate active threads\n";
 333   my @tstmps = sort { $a <=> $b } keys(%timestamps);
 334   foreach my $label ('entire', @labels) {
 335     print "tracking $label\n";
 336     foreach my $thread (@threads) {
 337       foreach my $tstmp (@tstmps) {
 338         if($gthreads{$thread}{'first'} <= $tstmp && $gthreads{$thread}{'last'} >= $tstmp) {
 339           $glabels{$label}{$tstmp, 'activethreads'} += 1;
 340         }
 341       }
 342     }
 343   }
 344 }
 345 
 346 #---
 347 # charts will be created
 348 # if something could be parsed
 349 #---
 350 if($respcount > 0) {
 351   #---
 352   # number of time stamps
 353   #---
 354   $measures = scalar(keys(%timestamps));
 355 
 356   # Write the html header
 357   print $INDEXFILE <<EndOfHTML;
 358   <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
 359   <html lang="en-US" xml:lang="en-US" xmlns="http://www.w3.org/1999/xhtml">
 360   <head>
 361 
 362   <title>HTML Images</title>
 363 
 364   <body>
 365 EndOfHTML
 366 
 367 
 368   print "Generating stacked bars absolute\n" if $DEBUG;
 369   &ChartStackedBars();
 370   # Write this to the index
 371 
 372 
 373   print "Generating stacked bars relative\n" if $DEBUG;
 374   &ChartStackedPct();
 375   
 376   foreach my $label ('entire', @labels) {
 377     if($entireby > 0) {
 378       &ChartLines($label, 'throughput');
 379     }
 380     &ChartLines($label, 'users');
 381   }
 382   # Close our HTML
 383   print $INDEXFILE "</body>\n";
 384   print $INDEXFILE "</html>\n";
 385   close($INDEXFILE);
 386 }
 387 #-------------------------------------------------------------------------------
 388 sub ChartStackedPct {
 389 
 390   if(scalar(@labels) == 0) {
 391     return undef;
 392   }
 393 
 394   my $ChartStacked = Chart::StackedBars->new(1024, 768);
 395 
 396   #---
 397   # cusps
 398   #---
 399   my @xaxis = ();
 400   my @xlabels = ();
 401   foreach my $label (@labels) {
 402     print "Attempting to add $label to StackedPCT graph\n" if $DEBUG;
 403     if(($glabels{$label}{'respcount'} > ($respcount / 100)) || grep(/-alllb/i, @args)) {
 404       push(@xaxis, $label);
 405       push(@xlabels, $label);
 406       print " Added $label\n" if $DEBUG;
 407     }
 408   }
 409   $ChartStacked->add_dataset(@xlabels);
 410 
 411   my ($value,$i,$label);
 412   my @data = ();
 413   my @legend_labels = ();
 414 
 415   for($i = 0; $i <= $#cusps; $i++) {
 416     @data = ();
 417     foreach my $label (@xaxis) {
 418       $value = $glabels{$label}{$cusps[$i]};
 419       if(!defined $value) {
 420         $value = 0;
 421       }
 422       $value = (100 * $value) / $glabels{$label}{'respcount'};
 423       push(@data, $value);
 424     }
 425     $ChartStacked->add_dataset(@data);
 426 
 427     push(@legend_labels, "< " . $cusps[$i] . " msec");
 428   }
 429 
 430   my %settings = (
 431     transparent => 'true',
 432     title => 'Response Time %',
 433     y_grid_lines => 'true',
 434     legend => 'right',
 435     legend_labels => \@legend_labels,
 436     precision => 0,
 437     y_label => 'Requests %',
 438     max_val => 100,
 439     include_zero => 'true',
 440     point => 0,
 441     colors => \%colors,
 442     x_ticks => 'vertical',
 443     precision => 0,
 444   );
 445 
 446   $ChartStacked->set(%settings);
 447 
 448   print "Generated ChartStackedPct.png\n" if $DEBUG;
 449   $ChartStacked->png("ChartStackedPct.png");
 450   print $INDEXFILE "<img src=\"ChartStackedPct.png\"/>\n";
 451 }
 452 #-------------------------------------------------------------------------------
 453 sub ChartStackedBars {
 454 
 455   if(scalar(@labels) == 0) {
 456     return undef;
 457   }
 458 
 459   my $ChartStacked = Chart::StackedBars->new(1024, 768);
 460 
 461   #---
 462   # cusps
 463   #---
 464   my @xaxis = ();
 465   my @xlabels = ();
 466   foreach my $label (@labels) {
 467     print "Added $label to StackedPCT graph\n" if $DEBUG;
 468     if(($glabels{$label}{'respcount'} > ($respcount / 100)) || grep(/-alllb/i, @args)) {
 469       push(@xaxis, $label);
 470       if(length($label) > 30) {
 471         push(@xlabels, substr($label, -30));
 472       } else {
 473         push(@xlabels, $label);
 474       }
 475     }
 476   }
 477   $ChartStacked->add_dataset(@xlabels);
 478 
 479   my ($value,$i,$label);
 480   my @data = ();
 481   my @legend_labels = ();
 482   for($i = 0; $i <= $#cusps; $i++) {
 483     @data = ();
 484     foreach my $label (@xaxis) {
 485       $value = $glabels{$label}{$cusps[$i]};
 486       if($value == undef) {
 487         $value = 0;
 488       }
 489       push(@data, $value);
 490     }
 491     $ChartStacked->add_dataset(@data);
 492 
 493     push(@legend_labels, "< " . $cusps[$i] . " msec");
 494   }
 495 
 496   my %settings = (
 497     transparent => 'true',
 498     title => 'Response Time',
 499     y_grid_lines => 'true',
 500     legend => 'right',
 501     legend_labels => \@legend_labels,
 502     precision => 0,
 503     y_label => 'Requests',
 504     include_zero => 'true',
 505     point => 0,
 506     colors => \%colors,
 507     x_ticks => 'vertical',
 508     precision => 0,
 509   );
 510 
 511   $ChartStacked->set(%settings);
 512 
 513   print "Generating ChartStacked.png\n" if $DEBUG;
 514   $ChartStacked->png("ChartStacked.png");
 515   print $INDEXFILE "<img src=\"ChartStacked.png\"/>\n";
 516 }
 517 #-------------------------------------------------------------------------------
 518 sub ChartLines {
 519   my ($label, $mode) = @_;
 520 
 521   my %labelmap = (
 522     'entire' => 'total',
 523   );
 524 
 525   my $title = $label;
 526         $title = $labelmap{$label} if($labelmap{$label});
 527 
 528   my $ChartComposite = Chart::Composite->new(1024, 768);
 529 
 530   my @tstmps = sort { $a <=> $b } keys(%timestamps);
 531   my @responsetimes = ();
 532   my @plusstddev = ();
 533   my @minusstddev = ();
 534   my @users = ();
 535   my @throughput = ();
 536   my @xaxis = ();
 537   my $y2label;
 538 
 539 
 540   #---
 541   # response times
 542   #---
 543   my $tstmp;
 544   my ($pstd, $mstd) = (0, 0);
 545   foreach my $tstmp (@tstmps) {
 546     if($glabels{$label}{$tstmp, 'avg'}) {
 547       push(@xaxis, $tstmp);
 548       push(@responsetimes, $glabels{$label}{$tstmp, 'avg'});
 549 
 550       $mstd = $glabels{$label}{$tstmp, 'avg'} - $glabels{$label}{$tstmp, 'stddev'};
 551       $pstd = $glabels{$label}{$tstmp, 'avg'} + $glabels{$label}{$tstmp, 'stddev'};
 552       $mstd = 1 if($mstd < 0);  # supress lines below 0
 553       push(@plusstddev, $pstd);
 554       push(@minusstddev, $mstd);
 555     }
 556   }
 557   $ChartComposite->add_dataset(@xaxis);
 558   $ChartComposite->add_dataset(@responsetimes);
 559 
 560   my %colors = (
 561     dataset0 => "green",
 562     dataset1 => "red",
 563   );
 564   my @ds1 = (1);
 565   my @ds2 = (2);
 566   if(grep(/-stddev/ || /-range/, @args)) {
 567     $ChartComposite->add_dataset(@plusstddev);
 568     $ChartComposite->add_dataset(@minusstddev);
 569     @ds1 = (1, 2, 3);
 570     @ds2 = (4);
 571 
 572     %colors = (
 573       dataset0 => "green",
 574             dataset1 => [189, 183, 107],  # dark khaki
 575             dataset2 => [189, 183, 107],  # dark khaki
 576       dataset3 => "red",
 577     );
 578   }
 579 
 580   if($mode eq 'users') {
 581     #---
 582     # users
 583     #---
 584     foreach my $tstmp (@xaxis) {
 585       push(@users, $glabels{$label}{$tstmp, 'activethreads'});
 586     }
 587   
 588     $ChartComposite->add_dataset(@users);
 589     $y2label = "active threads";
 590   } else {
 591     #---
 592     # throughput
 593     #---
 594     foreach my $tstmp (@xaxis) {
 595       push(@throughput, $glabels{$label}{$tstmp, 'throughput'});
 596     }
 597     $ChartComposite->add_dataset(@throughput);
 598     $y2label = "throughput bytes/min";
 599   }
 600 
 601   my $skip = 0;
 602   if(scalar(@xaxis) > 40) {
 603     $skip = int(scalar(@xaxis) / 40) + 1;
 604   }
 605 
 606   my @labels = ($label, $mode);
 607   if(grep(/-stddev/, @args)) {
 608     @labels = ($label, "+stddev", "-stddev", $mode);
 609   }
 610 
 611   my $type = 'Lines';
 612   if(grep(/-range/i, @args)) {
 613     @labels = ($label, "n.a", "n.a", $mode);
 614     $type = 'ErrorBars';
 615   }
 616 
 617   my %settings = (
 618     composite_info => [ [$type, \@ds1], ['Lines', \@ds2 ]],
 619     transparent => 'true',
 620     title => 'Response Time ' . $title,
 621     y_grid_lines => 'true',
 622     legend => 'bottom',
 623     y_label => 'Response Time msec',
 624     y_label2 => $y2label,
 625     legend_labels => \@labels,
 626     legend_example_height => 1,
 627     legend_example_height0 => 10,
 628     legend_example_height1 => 2,
 629     legend_example_height2 => 2,
 630     legend_example_height3 => 10,
 631     legend_example_height4 => 10,
 632     include_zero => 'true',
 633     x_ticks => 'vertical',
 634     skip_x_ticks => $skip,
 635     brush_size1 => 3,
 636     brush_size2 => 3,
 637     pt_size => 6,
 638     point => 0,
 639     line => 1,
 640     f_x_tick => \&formatTime,
 641     colors => \%colors,
 642     precision => 0,
 643   );
 644 
 645   $ChartComposite->set(%settings);
 646 
 647   my $filename = $label;
 648   $filename=~ s/\W/_/g;
 649   $filename .= '_' . $mode . '.png';
 650   print $filename, "\n";
 651 
 652   $ChartComposite->png($filename);
 653   # Write the link to the index file
 654   #print "Adding html link\n" if $DEBUG;
 655   print $INDEXFILE '<img src="' . $filename . "\"/>\n";
 656 
 657 }
 658 #-------------------------------------------------------------------------------
 659 sub formatTime {
 660   my ($tstmp) = @_;
 661 
 662   my $string = scalar(localtime($tstmp));
 663 
 664   my ($rc) = ($string =~ /\s(\d\d:\d\d:\d\d)\s/);
 665 
 666   return $rc;
 667 }
 668 #-------------------------------------------------------------------------------
 669 #-------------------------------------------------------------------------------

Attached Files

To refer to attachments on a page, use attachment:filename, as shown below in the list of files. Do NOT use the URL of the [get] link, since this is subject to change and can break easily.

You are not allowed to attach a file to this page.