[CalendarServer-changes] [7280] CalendarServer/trunk/contrib/tools/protocolanalysis.py
source_changes at macosforge.org
source_changes at macosforge.org
Thu Mar 31 13:17:25 PDT 2011
Revision: 7280
http://trac.macosforge.org/projects/calendarserver/changeset/7280
Author: cdaboo at apple.com
Date: 2011-03-31 13:17:24 -0700 (Thu, 31 Mar 2011)
Log Message:
-----------
Improve the protocol analysis tool to better handle time ranges in the access log vs utc offsets. Also do more protocol
analysis to include CardDAV operations.
Modified Paths:
--------------
CalendarServer/trunk/contrib/tools/protocolanalysis.py
Modified: CalendarServer/trunk/contrib/tools/protocolanalysis.py
===================================================================
--- CalendarServer/trunk/contrib/tools/protocolanalysis.py 2011-03-31 14:51:04 UTC (rev 7279)
+++ CalendarServer/trunk/contrib/tools/protocolanalysis.py 2011-03-31 20:17:24 UTC (rev 7280)
@@ -1,6 +1,6 @@
#!/usr/bin/env python
##
-# Copyright (c) 2009-2010 Apple Inc. All rights reserved.
+# Copyright (c) 2009-2011 Apple Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -26,9 +26,6 @@
import tables
import traceback
-UTC_OFFSET = -8
-UTC_START_HOUR = 5
-
def safePercent(x, y, multiplier=100):
return ((multiplier * x) / y) if y else 0
@@ -151,6 +148,7 @@
self,
startHour=None,
endHour=None,
+ utcoffset = None,
resolutionMinutes=60,
filterByUser=None,
filterByClient=None,
@@ -159,13 +157,11 @@
self.startHour = startHour
self.endHour = endHour
+ self.utcoffset = utcoffset
self.filterByUser = filterByUser
self.filterByClient = filterByClient
self.ignoreNonHTTPMethods = ignoreNonHTTPMethods
- self.utcStartHour = UTC_START_HOUR
- self.autoUTC = True
- self.localtimeOffset = UTC_OFFSET
self.startTime = datetime.datetime.now().replace(microsecond=0)
self.host = socket.getfqdn()
@@ -174,6 +170,7 @@
self.resolutionMinutes = resolutionMinutes
self.timeBucketCount = (24 * 60) / resolutionMinutes
+ self.loggedUTCOffset = None
self.hourlyTotals = [[0, 0, 0, collections.defaultdict(int), 0.0,] for _ignore in xrange(self.timeBucketCount)]
@@ -200,7 +197,7 @@
self.requestSizeByBucket = collections.defaultdict(lambda:[0,] * self.timeBucketCount)
self.responseSizeByBucket = collections.defaultdict(lambda:[0,] * self.timeBucketCount)
- self.averageResponseCountByMethod = collections.defaultdict(lambda: [0, 0])
+ self.responseCountByMethod = collections.defaultdict(lambda: [0, 0])
self.requestTimeByBucket = collections.defaultdict(lambda:[0,] * self.timeBucketCount)
@@ -222,9 +219,7 @@
else:
f = open(fpath)
- lastHourFromStart = -1
- self.startHourFromStart = divmod(self.startHour - (self.utcStartHour + self.localtimeOffset), self.timeBucketCount)[1]
- self.endHourFromStart = divmod(self.endHour - (self.utcStartHour + self.localtimeOffset), self.timeBucketCount)[1]
+ self.maxIndex = (self.endHour - self.startHour + 1) * 60 / self.resolutionMinutes
try:
ctr = 0
for line in f:
@@ -244,20 +239,12 @@
# Do hour ranges
logHour = int(self.currentLine.logTime[0:2])
logMinute = int(self.currentLine.logTime[3:5])
- if self.autoUTC:
- self.utcStartHour = logHour
- self.localtimeOffset = -8 if logHour == 6 else -7
- self.startHourFromStart = divmod(self.startHour - (self.utcStartHour + self.localtimeOffset), 24)[1]
- self.endHourFromStart = divmod(self.endHour - (self.utcStartHour + self.localtimeOffset), 24)[1]
- self.autoUTC = False
- hourFromStart = divmod(logHour - self.utcStartHour, 24)[1]
-# if hourFromStart > 1:
-# break
- if hourFromStart > lastHourFromStart:
- lastHourFromStart = hourFromStart
- if hourFromStart < self.startHourFromStart:
+ hourFromStart = logHour - (0 if self.utcoffset is None else self.utcoffset) - self.startHour
+ if hourFromStart < 0:
+ hourFromStart += 24
+ if logHour < self.startHour:
continue
- elif hourFromStart > self.endHourFromStart:
+ elif logHour > self.endHour:
continue
timeBucketIndex = (hourFromStart * 60 + logMinute) / self.resolutionMinutes
@@ -366,10 +353,10 @@
self.responseSizeByBucket[self.getCountBucket(self.currentLine.bytes, responseSizeBuckets)][timeBucketIndex] += 1
if rcount != -1:
- self.averageResponseCountByMethod[" TOTAL"][0] += rcount
- self.averageResponseCountByMethod[" TOTAL"][1] += 1
- self.averageResponseCountByMethod[adjustedMethod][0] += rcount
- self.averageResponseCountByMethod[adjustedMethod][1] += 1
+ self.responseCountByMethod[" TOTAL"][0] += rcount
+ self.responseCountByMethod[" TOTAL"][1] += 1
+ self.responseCountByMethod[adjustedMethod][0] += rcount
+ self.responseCountByMethod[adjustedMethod][1] += 1
# Request time analysis
self.requestTimeByBucket[" TOTAL"][timeBucketIndex] += 1
@@ -412,7 +399,7 @@
self.averagedHourlyByRecipientCount[method][hour] = newValue
averaged = collections.defaultdict(int)
- for key, value in self.averageResponseCountByMethod.iteritems():
+ for key, value in self.responseCountByMethod.iteritems():
averaged[key] = (value[0] / value[1]) if value[1] else 0
self.averageResponseCountByMethod = averaged
@@ -421,39 +408,6 @@
count = self.clientByMethodCount[client][method]
self.clientByMethodAveragedTime[client][method] = totaltime/count if count else 0
-#regex1 = re.compile("""[^ ]+ - (?P<userid>"[^"]+"|[^ ]+) \[[^:]+:(?P<logTime>[^ ]+) [^"]+"(?P<method>\?\?\?|[^ ]+)( (?P<uri>[^ ]+) HTTP/..."|") (?P<status>[^ ]+) (?P<bytes>[^ ]+) "(?P<referrer>[^"]+)" "(?P<client>[^"]+)" (?P<rest>.*)""")
-#regex2 = re.compile("""\[(?P<time>[^ ]+) ms\] \[(?P<port>[^ ]+) (?P<qdepth>[^ ]+)\]""")
-#
-#def parseLineR(line):
-#
-# m = regex1.match(line)
-#
-# userid = m.group("userid")
-# logTime = m.group("logTime")
-# method = m.group("method")
-# if method == "???":
-# uri = ""
-# else:
-# uri = m.group("uri")
-# status = int(m.group("status"))
-# bytes = int(m.group("bytes"))
-# referrer = m.group("referrer")
-# client = m.group("client")
-# rest = m.group("rest")
-#
-# if rest[0] == '[':
-# m = regex2.match(rest)
-#
-# extended = {}
-# extended["t"] = float(m.group("time"))
-# extended["i"] = int(m.group("port"))
-# extended["or"] = int(m.group("qdepth"))
-# else:
-# items = rest.split()
-# extended = dict([item.split('=') for item in items])
-#
-# return userid, logTime, method, uri, status, bytes, referrer, client, extended
-
def parseLine(self, line):
startPos = line.find("- ")
@@ -463,6 +417,9 @@
startPos = endPos + 1
logDateTime = line[startPos + 1:startPos + 21]
logTime = line[startPos + 13:startPos + 21]
+
+ if self.loggedUTCOffset is None:
+ self.loggedUTCOffset = int(line[startPos + 22:startPos + 25])
startPos = line.find(']', startPos + 21) + 3
endPos = line.find(' ', startPos)
@@ -521,44 +478,12 @@
index = self.currentLine.client.find("iCal/")
if index != -1:
name = self.currentLine.client[index:self.currentLine.client.find(' ', index)]
- if name.startswith("iCal/3"):
- return "iCal/3"
- elif name == "iCal/4.0":
- return "iCal/4.0"
- elif name == "iCal/4.0.1":
- return "iCal/4.0.1"
- elif name == "iCal/4.0.2":
- return "iCal/4.0.2"
- elif name == "iCal/4.0.3":
- return "iCal/4.0.3"
- elif name.startswith("iCal/4"):
- return "iCal/4.?"
- elif name.startswith("iCal/5"):
- return "iCal/5.?"
- else:
- return "iCal/???"
+ return name
index = self.currentLine.client.find("iPhone/")
if index != -1:
name = self.currentLine.client[index:self.currentLine.client.find(' ', index)]
- if name.startswith("iPhone/3.0"):
- return "iPhone/3.0"
- elif name.startswith("iPhone/3.1"):
- return "iPhone/3.1"
- elif name.startswith("iPhone/3.2"):
- return "iPhone/3.2"
- elif name.startswith("iPhone/3"):
- return "iPhone/3.?"
- elif name.startswith("iPhone/4.0"):
- return "iPhone/4.0"
- elif name.startswith("iPhone/4.1"):
- return "iPhone/4.1"
- elif name.startswith("iPhone/4.2"):
- return "iPhone/4.2"
- elif name.startswith("iPhone/4"):
- return "iPhone/4.?"
- else:
- return "iPhone/???"
+ return name
index = self.currentLine.client.find("calendarclient")
if index != -1:
@@ -570,6 +495,18 @@
else:
return "Simulator"
+ quickclients = (
+ ("CardDAVPlugin/", "CardDAVPlugin"),
+ ("Address%20Book/", "AddressBook"),
+ ("AddressBook/", "AddressBook"),
+ ("Mail/", "Mail"),
+ ("iChat/", "iChat"),
+ )
+ for quick, result in quickclients:
+ index = self.currentLine.client.find(quick)
+ if index != -1:
+ return result
+
return "Other"
def getAdjustedMethodName(self):
@@ -586,18 +523,49 @@
return "PROPFIND%s Calendar Home" % ("-cached" if cached else "")
elif uribits[3] == "inbox":
return "PROPFIND Inbox"
- elif uribits[3] == "dropbox":
+ elif uribits[3] == ("dropbox", "freebusy"):
pass
+ elif uribits[3] == "notification":
+ return "PROPFIND Notification"
else:
return "PROPFIND Calendar"
+ elif uribits[0] == "addressbooks":
+
+ if len(uribits) > 3:
+ if uribits[3] == "":
+ return "PROPFIND%s Adbk Home" % ("-cached" if cached else "")
+ elif uribits[3] == "notification":
+ return "PROPFIND Notification"
+ else:
+ return "PROPFIND Adbk"
+
+ elif uribits[0] == "directory":
+ return "PROPFIND%s directory" % ("-cached" if cached else "")
+
elif uribits[0] == "principals":
return "PROPFIND%s Principal" % ("-cached" if cached else "")
elif self.currentLine.method.startswith("REPORT"):
if "(" in self.currentLine.method:
- return "REPORT %s" % self.currentLine.method.split(":")[1][:-1]
+ report_type = self.currentLine.method.split("}" if "}" in self.currentLine.method else ":")[1][:-1]
+ if report_type == "addressbook-query":
+ uribits = self.currentLine.uri.split('/')[1:]
+ if uribits[0] == "directory":
+ report_type = "directory-query"
+ shorter = {
+ "calendar-multiget" : "cal-multi",
+ "addressbook-multiget" : "adbk-multi",
+ "calendar-query" : "cal-query",
+ "addressbook-query" : "adbk-query",
+ "directory-query" : "directory",
+ "sync-collection" : "sync",
+ "principal-search-property-set" : "p-set",
+ "principal-property-search" : "p-search",
+ "expand-property" : "expand",
+ }
+ return "REPORT %s" % (shorter.get(report_type, report_type),)
elif self.currentLine.method == "PROPPATCH":
@@ -608,14 +576,48 @@
elif self.currentLine.method == "POST":
- if "freebusy" in self.currentLine.extended:
- return "POST Freebusy"
- else:
- for key in ("itip.publish", "itip.request", "itip.cancel", "itip.add", "itip.decline-counter",):
- if key in self.currentLine.extended:
- return "POST iTIP Organizer"
+ uribits = self.currentLine.uri.split('/')[1:]
+
+ if uribits[0] == "calendars" and len(uribits) > 3 and uribits[3] == "outbox":
+ if "freebusy" in self.currentLine.extended:
+ return "POST Freebusy"
else:
- return "POST iTIP Attendee"
+ for key in ("itip.publish", "itip.request", "itip.cancel", "itip.add", "itip.decline-counter",):
+ if key in self.currentLine.extended:
+ return "POST Organizer"
+ else:
+ return "POST Attendee"
+ elif uribits[0] == "calendars":
+
+ if len(uribits) > 3:
+ if uribits[3] == "":
+ return "POST Calendar Home"
+ elif uribits[3] == "outbox":
+ if "freebusy" in self.currentLine.extended:
+ return "POST Freebusy"
+ else:
+ for key in ("itip.publish", "itip.request", "itip.cancel", "itip.add", "itip.decline-counter",):
+ if key in self.currentLine.extended:
+ return "POST Organizer"
+ else:
+ return "POST Attendee"
+ elif uribits[3] in ("dropbox", "freebusy", "notification"):
+ pass
+ else:
+ return "POST Calendar"
+
+ elif uribits[0] == "addressbooks":
+
+ if len(uribits) > 3:
+ if uribits[3] == "":
+ return "POST Adbk Home"
+ elif uribits[3] == "notification":
+ pass
+ else:
+ return "POST Adbk"
+
+ elif uribits[0].startswith("timezones"):
+ return "POST Timezones"
elif self.currentLine.method == "PUT":
@@ -628,9 +630,33 @@
uribits = self.currentLine.uri.split('/')[1:]
- if len(uribits) > 3 and uribits[3] == "dropbox":
- return "GET Dropbox"
-
+ if uribits[0] == "calendars":
+
+ if len(uribits) > 3:
+ if uribits[3] == "":
+ return "GET Calendar Home"
+ elif uribits[3] in ("notification", "outbox"):
+ pass
+ elif uribits[3] == "dropbox":
+ return "GET Dropbox"
+ elif uribits[3] == "freebusy":
+ return "GET Freebusy"
+ else:
+ return "GET Calendar"
+
+ elif uribits[0] == "addressbooks":
+
+ if len(uribits) > 3:
+ if uribits[3] == "":
+ return "GET Adbk Home"
+ elif uribits[3] == "notification":
+ pass
+ else:
+ return "GET Adbk"
+
+ elif uribits[0].startswith("timezones"):
+ return "GET Timezones"
+
return self.currentLine.method
def getCountBucket(self, count, buckets):
@@ -773,8 +799,8 @@
print "URI Counts"
self.printURICounts(doTabs)
- print "User Interaction Counts"
- self.printUserInteractionCounts(doTabs)
+ #print "User Interaction Counts"
+ #self.printUserInteractionCounts(doTabs)
#print "User Weights (top 100)"
#self.printUserWeights(doTabs)
@@ -800,24 +826,21 @@
def getHourFromIndex(self, index):
+ if index >= self.maxIndex:
+ return None
totalminutes = index * self.resolutionMinutes
offsethour, minute = divmod(totalminutes, 60)
- utchour = divmod(offsethour + self.utcStartHour, 24)[1]
- localhour = divmod(utchour + self.localtimeOffset, 24)[1]
+ localhour = divmod(offsethour + self.startHour, 24)[1]
+ utchour = divmod(localhour - self.loggedUTCOffset, 24)[1]
# Clip to select hour range
- if offsethour < self.startHourFromStart:
- return None
- elif offsethour > self.endHourFromStart:
- return None
- else:
- return "%02d:%02d (%02d:%02d)" % (utchour, minute, localhour, minute,)
+ return "%02d:%02d (%02d:%02d)" % (localhour, minute, utchour, minute,)
def printHourlyTotals(self, doTabs):
table = tables.Table()
- table.addHeader(("Hour UTC (PDT)", "Total", "Av. Queue", "Max. Queue", "Av. Response",))
+ table.addHeader(("Local (UTC)", "Total", "Av. Queue", "Max. Queue", "Av. Response",))
table.addHeader(("", "Requests", "Depth", "Depth (# queues)", "Time(ms)",))
table.setDefaultColumnFormats((
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY),
@@ -905,7 +928,7 @@
totals = [0,] * len(hourlyByXXX)
table = tables.Table()
- header = ["Hour UTC (PDT)",]
+ header = ["Local (UTC)",]
header2 = ["",]
use_header2 = False
formats = [tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY),]
@@ -963,17 +986,19 @@
def printHourlyCacheDetails(self, doTabs):
- totals = [0,] * 5
+ totals = [0,] * 7
table = tables.Table()
- header1 = ["Hour UTC (PDT)", "PROPFIND Calendar Home", "", "PROPFIND Principals", ""]
- header2 = ["", "Uncached", "Cached", "Uncached", "Cached"]
+ header1 = ["Local (UTC)", "PROPFIND Calendar Home", "", "PROPFIND Address Book Home", "", "PROPFIND Principals", ""]
+ header2 = ["", "Uncached", "Cached", "Uncached", "Cached", "Uncached", "Cached"]
formats = [
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY),
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+ tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+ tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
]
table.addHeader(header1, columnFormats = [
@@ -982,6 +1007,8 @@
None,
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY, span=2),
None,
+ tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY, span=2),
+ None,
])
table.addHeaderDivider(skipColumns=(0,))
@@ -991,6 +1018,8 @@
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY),
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY),
tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY),
+ tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY),
+ tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.CENTER_JUSTIFY),
])
table.setDefaultColumnFormats(formats)
@@ -1002,25 +1031,34 @@
row = []
row.append(hour)
- homeUncached = self.hourlyByOKMethodCount["PROPFIND Calendar Home"][ctr]
- homeCached = self.hourlyByOKMethodCount["PROPFIND-cached Calendar Home"][ctr]
- homeTotal = homeUncached + homeCached
+ calHomeUncached = self.hourlyByOKMethodCount["PROPFIND Calendar Home"][ctr]
+ calHomeCached = self.hourlyByOKMethodCount["PROPFIND-cached Calendar Home"][ctr]
+ calHomeTotal = calHomeUncached + calHomeCached
+ adbkHomeUncached = self.hourlyByOKMethodCount["PROPFIND Adbk Home"][ctr]
+ adbkHomeCached = self.hourlyByOKMethodCount["PROPFIND-cached Adbk Home"][ctr]
+ adbkHomeTotal = adbkHomeUncached + adbkHomeCached
+
principalUncached = self.hourlyByOKMethodCount["PROPFIND Principal"][ctr]
principalCached = self.hourlyByOKMethodCount["PROPFIND-cached Principal"][ctr]
principalTotal = principalUncached + principalCached
- row.append("%d (%2d%%)" % (homeUncached, safePercent(homeUncached, homeTotal),))
- row.append("%d (%2d%%)" % (homeCached, safePercent(homeCached, homeTotal),))
+ row.append("%d (%2d%%)" % (calHomeUncached, safePercent(calHomeUncached, calHomeTotal),))
+ row.append("%d (%2d%%)" % (calHomeCached, safePercent(calHomeCached, calHomeTotal),))
+ row.append("%d (%2d%%)" % (adbkHomeUncached, safePercent(adbkHomeUncached, adbkHomeTotal),))
+ row.append("%d (%2d%%)" % (adbkHomeCached, safePercent(adbkHomeCached, adbkHomeTotal),))
+
row.append("%d (%2d%%)" % (principalUncached, safePercent(principalUncached, principalTotal),))
row.append("%d (%2d%%)" % (principalCached, safePercent(principalCached, principalTotal),))
- totals[1] += homeUncached
- totals[2] += homeCached
- totals[3] += principalUncached
- totals[4] += principalCached
+ totals[1] += calHomeUncached
+ totals[2] += calHomeCached
+ totals[3] += adbkHomeUncached
+ totals[4] += adbkHomeCached
+ totals[5] += principalUncached
+ totals[6] += principalCached
table.addRow(row)
@@ -1030,6 +1068,8 @@
row.append("%d (%2d%%)" % (totals[2], safePercent(totals[2], totals[1] + totals[2]),))
row.append("%d (%2d%%)" % (totals[3], safePercent(totals[3], totals[3] + totals[4]),))
row.append("%d (%2d%%)" % (totals[4], safePercent(totals[4], totals[3] + totals[4]),))
+ row.append("%d (%2d%%)" % (totals[5], safePercent(totals[5], totals[5] + totals[6]),))
+ row.append("%d (%2d%%)" % (totals[6], safePercent(totals[6], totals[5] + totals[6]),))
table.addFooter(row)
table.printTabDelimitedData() if doTabs else table.printTable()
@@ -1546,7 +1586,8 @@
print """Usage: protocolanalysis [options] [FILE]
Options:
-h Print this help and exit
- --hours Range of hours to analyze
+ --hours Range of hours (local time) to analyze [0:23]
+ --utcoffset Local time offset for UTC log entries
--resolution Time resolution in minutes [60]
--user User to analyze
--client Client to analyze
@@ -1576,12 +1617,13 @@
doTabDelimited = False
repeat = False
resolution = 60
- startHour = 22
+ startHour = 0
endHour = startHour + 23
+ utcoffset = None
filterByUser = None
filterByClient = None
- options, args = getopt.getopt(sys.argv[1:], "h", ["diff", "hours=", "resolution=", "repeat", "tabs", "user=", "client=", ])
+ options, args = getopt.getopt(sys.argv[1:], "h", ["diff", "hours=", "utcoffset=", "resolution=", "repeat", "tabs", "user=", "client=", ])
for option, value in options:
if option == "-h":
@@ -1594,11 +1636,18 @@
doTabDelimited = True
elif option == "--hours":
splits = value.split(":")
- if len(splits) != 2:
+ if len(splits) not in (1, 2):
usage("Wrong format for --hours: %s %s" % (value, splits))
+ elif len(splits) == 1:
+ startHour = int(splits[0])
+ endHour = startHour + 24
else:
startHour = int(splits[0])
endHour = int(splits[1])
+ if endHour < startHour:
+ endHour += 24
+ elif option == "--utcoffset":
+ utcoffset = int(value)
elif option == "--resolution":
resolution = int(value)
elif option == "--user":
@@ -1631,7 +1680,7 @@
continue
if diffMode or not analyzers:
- analyzers.append(CalendarServerLogAnalyzer(startHour, endHour, resolution, filterByUser, filterByClient))
+ analyzers.append(CalendarServerLogAnalyzer(startHour, endHour, utcoffset, resolution, filterByUser, filterByClient))
analyzers[-1].analyzeLogFile(arg)
if diffMode and len(analyzers) > 1:
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20110331/7f8eee48/attachment-0001.html>
More information about the calendarserver-changes
mailing list