#!/usr/bin/env python # coding: utf-8 # ## Присоединяйтесь к чату воркшопа: # # https://clck.ru/9yRpd # ## Подходите за материалами (на флешках) # # Один день из жизни нагрузочного тестировщика # ## Алексей Лавренюк, Яндекс # Процесс нагрузочного тестирования: # # * Анализ работы сервиса: # * архитектура # * нагрузка (планируемая или существующая) # * Подготовка стрельб # * настройка тестового стенда # * подготовка патронов # * [Стрельбы](http://www.failoverconf.ru/upload/iblock/8ce/2_01_YandexTank.pdf) # * Анализ результатов # # Из вышеперечисленного сегодня мы займемся анализом входной нагрузки и анализом результатов стрельб. # ## Задачка 0: готовим тестовое окружение # # Логи, с которыми мы будем работать -- на флешках: # # * `access_log_Jul95` -- логи веб-сервера NASA Kennedy Space Center за июль 1995 года # * `phout_1` и `phout_2` -- логи стрельб Яндекс.Танком # # Страничка с описанием логов NASA: http://ita.ee.lbl.gov/html/contrib/NASA-HTTP.html # # Если будете использовать jupyter, запустите его в той папке, куда скачали логи. # # Проверьте, что библиотеки имортируются: # ``` # import numpy as np # numpy # import pandas as pd # pandas # # # альтернативная библиотека для визуализации # # https://github.com/mwaskom/seaborn # import seaborn as sns # # # подключаем графику прямо в браузер # %matplotlib inline # ``` # Если нет -- самое время их поставить. # ``` # sudo -H pip3 install numpy pandas seaborn # ``` # Если вы под ubuntu, вероятно, для сборки этих библиотек вам понадобятся: # ``` # sudo apt install build-essential python-dev gfortran # ``` # # Парсим access.log # In[ ]: # немного о jupyter. vim-like, помощь, автодополнение # In[ ]: # In[3]: import numpy as np get_ipython().run_line_magic('pinfo', 'np.linspace') # In[4]: get_ipython().run_cell_magic('bash', '', '# можно исполнять bash-команды прямо отсюда\n\nhead ./access_log_Jul95\n') # In[5]: # мы на PyCon, поэтому будем работать с помощью преимущественно в Python # читаем первую строку: with open("./access_log_Jul95") as datafile: log_line = datafile.readline() print(log_line) # ## Задачка 1: парсим access.log # # **Задание** # Написать функцию `parse_line`, которая принимает на вход строку из лога и возвращает массив полей: # * hostname - имя хоста, например `199.72.81.55` # * date - дата в виде строки, например `01/Jul/1995:00:00:01 -0400` # * method - метод, указанный в запросе, например `GET` # * url - url, указанный в запросе, например `/history/apollo/` # * response_code - код ответа, например `200` # * size - размер ответа, например `6245` # # **вспомогательные материалы** # * функции [split](https://docs.python.org/3.5/library/stdtypes.html#str.split), [rsplit](https://docs.python.org/3.5/library/stdtypes.html#str.rsplit) и [strip](https://docs.python.org/3.5/library/stdtypes.html#str.strip) # * документация по [regex](https://docs.python.org/3/library/re.html) в питоне (а именно [re.match](https://docs.python.org/3/library/re.html#re.match)) # * [сервис](http://rubular.com) для тестирования регэкспов # # **заготовка кода** # ``` # with open("./access_log_Jul95") as datafile: # log_line = datafile.readline() # # def parse_line(line): # # ваш код тут # return [] # # print(parse_line(log_line)) # ``` # In[29]: # andrey-yantsen def parse_line_t(line): ip, _, _, dt, tz, method, path, _, responce_code, size = line.split(' ') return ip, dt + tz, method.replace('"', ''), path, responce_code, size def parse_line_t(line): ip, _, _, dt, tz, method, path, _, responce_code, size = line.split(' ') return ip, (dt + tz).replace('[', '').replace(']', ''), method.replace('"', ''), path, responce_code, size.strip() print(parse_line_t(log_line)) #





















#





















#





















# In[8]: # вариант с регэксом import re # регэкс скомпилим заранее regex = re.compile('([\w\-\.]+) - - \[(.*?)\] "([A-Z]+) (\S+)\s+(\S+)" (\d+) (\d+)') def parse_line_re(line): return regex.match(line).groups() parse_line_re(log_line) # In[9]: # вариант без регэкспа: постепенно откусываем поля по известным разделителям def parse_line_split(line): host, line = line.split(" ", 1) _, line = line.split("[", 1) date, line = line.split("]", 1) _, line = line.split('"', 1) req, line = line.split('"', 1) code, timing = line.strip("\r\n ").split() return [host, date] + req.split(" ") + [code, timing] parse_line_split(log_line) # In[31]: get_ipython().run_line_magic('timeit', 'parse_line_re(log_line)') get_ipython().run_line_magic('timeit', 'parse_line_split(log_line)') get_ipython().run_line_magic('timeit', 'parse_line_t(log_line)') # ## Тестируем на нашем логе # In[16]: def test_parser_on_file(parser): with open("./access_log_Jul95") as datafile: for line in datafile: try: parser(line) except Exception as e: # если парсер не сработает, напечатаем строку, # на которой он сломался, и выйдем print(line) raise e # In[17]: # сначала попробуем на функции, которая ничего не делает # python 2 работает по-другому =) test_parser_on_file(lambda l: l) #





















#





















#





















# In[18]: get_ipython().run_cell_magic('bash', '', "# посмотрим, что же это за 0x80\n\nhexdump ./access_log_Jul95 | grep -m 1 ' 80 '\n") # In[19]: get_ipython().run_cell_magic('bash', '', "\nxxd ./access_log_Jul95 | grep '0c86e60' -C 2 -m 1\n") # In[20]: # пофиксим читалку файла def test_parser_on_file(parser): with open("./access_log_Jul95", encoding="ascii", errors="replace") as datafile: for line in datafile: try: parser(line) except Exception as e: # если парсер не сработает, напечатаем строку, # на которой он сломался, и выйдем print(line) raise e test_parser_on_file(lambda l: l) # In[21]: # протестируем вариант с регэкспом test_parser_on_file(parse_line_re) # In[22]: test_parser_on_file(parse_line_t) #





















#





















#





















# In[23]: # если сервер возвращает ошибку, то размера нет # добавим поддержку для отсутствующего размера regex = re.compile('([\w\-\.]+) - - \[(.*?)\] "([A-Z]+) (\S+)\s+(\S+)" (\d+) (-|\d+)') def parse_line_re(line): return regex.match(line).groups() # и проверим, что все работает на этой строке parse_line_re('dd15-062.compuserve.com - - [01/Jul/1995:00:01:12 -0400] "GET /news/sci.space.shuttle/archive/sci-space-shuttle-22-apr-1995-40.txt HTTP/1.0" 404 -') # кстати, если бы мы парсили не регэкспом, а вручную, мы могли бы в случае отсутствия значения поставить там # 0 или None, и потом нам было бы удобнее работать с такими данными # In[24]: # попробуем еще раз test_parser_on_file(parse_line_re) # In[25]: # учитываем возможное отсутствие протокола в запросе regex = re.compile('([\w\-\.]+) - - \[(.*?)\] "([A-Z]+) (\S+)(?: (\S+))?" (\d+) (-|\d+)') def parse_line_re(line): return regex.match(line).groups() parse_line_re('pipe6.nyc.pipeline.com - - [01/Jul/1995:00:22:43 -0400] "GET /shuttle/missions/sts-71/movies/sts-71-mir-dock.mpg" 200 946425') # In[27]: # посмотрим все необработанные строки (опасно, но я пробовал) regex = re.compile('([\w\-\.@]+) - - \[(.*?)\] "([A-Z]+) (\S+)(?:\s+(\S+))?" (\d+) (-|\d+)') def parse_line_re(line): try: return regex.match(line).groups() except AttributeError: print(line) # не будем бросать тут эксепшн, просто продолжим raise return None test_parser_on_file(parse_line_re) # ## Задачка 2: починка парсера # # **Задание** Починить парсер хотя бы для тех ответов, которые обработал сервер # * нераспаршенными должны остаться только те ответы, на которые сервер выдал код 400 (bad request) # * предположим, что сервер читает url до первого пробельного символа, все остальное выкидывает # # **Заготовка кода** # # Можно либо поменять регулярное выражение, либо использовать свою функцию. # ``` # import re # # def test_parser_on_file(parser): # with open("./access_log_Jul95", encoding="ascii", errors="replace") as datafile: # for line in datafile: # try: # parser(line) # except Exception as e: # # если парсер не сработает, напечатаем строку, # # на которой он сломался, и выйдем # print(line) # raise e # # regex = re.compile('([\w\-\.@]+) - - \[(.*?)\] "([A-Z]+) (\S+)(?:\s+(\S+))?" (\d+) (-|\d+)') # def parse_line_re(line): # try: # return regex.match(line).groups() # except AttributeError: # print(line) # return None # # test_parser_on_file(parse_line_re) # ``` # In[34]: #briskly import re def parse_line(line): res = re.match(r"(.*)- - (\[.*\]) \"(\w+) (.+)( (.+))?\" (\d+) (\d+|-)", line) if res is None: print(line) return return res.groups() def parser_log(fn): for line in open(fn, encoding="ascii", errors="replace"): res = parse_line(line) if res: pass parser_log("access_log_Jul95") # In[28]: # beloborodov def parse_line(line): return re.search('(.*) - - \[(.*)\] "(.*) (\/.*) HTTP/1.0" (.*) (.*)', line).groups() test_parser_on_file(parse_line) #





















#





















#





















# **что получилось у меня** # * запросом считаем то, что после метода и до первого пробельного символа, далее выкидываем все до последней кавычки # * учитываем то, что после метода перед урлом может быть не один пробел # * добавил все символы, которые встречались в хостнеймах. Можно было просто ограничить хостнейм первым пробельным символом, но мне было интересно, какие бывают хостнеймы # In[35]: regex = re.compile('([\w\-\.@\'*,:/#&]+) - - \[(.*?)\] "([A-Z]+)\s+(\S+).*" (\d+) (-|\d+)') def parse_line_re(line): try: return regex.match(line).groups() except AttributeError: print(line) # не кидаем эксепшн, посмотрим все нераспаршенные строки return None test_parser_on_file(parse_line_re) # # Считаем урлы # In[36]: # убираем печать ненайденных урлов, чтобы не мешались regex = re.compile('([\w\-\.@\'*,:/#&]+) - - \[(.*?)\] "([A-Z]+)\s+(\S+).*" (\d+) (-|\d+)') def parse_line_re(line): try: return regex.match(line).groups() except AttributeError: return None # In[37]: from collections import defaultdict # нас интересуют урлы без параметров, параметры могут быть и через хеш, и через вопрос # отделим их с помощью регэкспа delim = re.compile("[#?]") counts = defaultdict(int) with open("./access_log_Jul95", encoding="ascii", errors="replace") as datafile: for line in datafile: fields = parse_line_re(line) if fields: uri = delim.split(fields[3])[0] counts[uri] += 1 counts # In[38]: import json with open("urls.json", "w") as of: json.dump(counts, of, indent=2) # ## Задачка 3: топ 10 урлов # # **Задание** Найти 10 самых посещаемых урлов и число посещений для каждого из них # # **Заготовка кода** # ``` # import re # from collections import defaultdict # # regex = re.compile('([\w\-\.@\'*,:/#&]+) - - \[(.*?)\] "([A-Z]+)\s+(\S+).*" (\d+) (-|\d+)') # def parse_line_re(line): # try: # return regex.match(line).groups() # except AttributeError: # return None # # # нас интересуют урлы без параметров, параметры могут быть и через хеш, и через вопрос # # отделим их с помощью регэкспа # delim = re.compile("[#?]") # # def count_urls(): # counts = defaultdict(int) # with open("./access_log_Jul95", encoding="ascii", errors="replace") as datafile: # for line in datafile: # fields = parse_line_re(line) # if fields: # uri = delim.split(fields[3])[0] # counts[uri] += 1 # return counts # # def top_ten(): # # ваш код здесь # return [] # ``` # In[41]: #andrey-yantsen import json urls = json.loads(open('urls.json').read()) list(sorted(urls.items(), key=lambda x: x[1], reverse=True))[:10] #





















#





















#





















# In[42]: # не оптимально, но нас устроит list(reversed(sorted(counts.items(), key=lambda x: x[1])))[:10] # # Подключаем Pandas # In[43]: import numpy as np # numpy import pandas as pd # pandas # альтернативная библиотека для визуализации # https://github.com/mwaskom/seaborn import seaborn as sns # подключаем графику прямо в браузер get_ipython().run_line_magic('matplotlib', 'inline') # In[44]: # основное понятие в Pandas -- DataFrame df = pd.DataFrame([ ["Stuart Bloom", False, 50], ["Sheldon Cooper", True, 2000], ["Amy Farrah Fowler", False, 1200], ["Radjesh Koothrappali", False, 1000], ["Howard Wolowitz", False, 1500], ["Bernadette Rostenkowski", True, 2100], ["Penny", True, 5000], ["Leonard Hofstadter", True, 2500], ], columns="name pythonista salary".split()) df # In[45]: # селектим только питонистов df[df['pythonista']] # In[46]: # как это работает? Индексация по массиву значений. df['pythonista'] # In[47]: df[[False, False, True, False, False, False, False, False]] # In[48]: # выберем только интересные нам колонки df[['pythonista', 'salary']] # In[49]: print(type(df['pythonista'])) df['pythonista'] # In[51]: # можно и так, но надо быть аккуратнее. Если колонка называется, например, size, # то не сработает -- потому что есть функция с таким же названием print(type(df.pythonista)) df.pythonista # In[50]: # выбираем одну колонку как датафрейм print(type(df[['pythonista']])) df[['pythonista']] # In[52]: # добавим новую колонку, приготовив ее из старой df['first_name'] = df['name'].apply(lambda x: x.split()[0]) df # ## Задачка 4: Добавить фамилию # # **Задание** Добавить колонку с фамилией. Если фамилия двойная, в колонке должны быть обе ее части. Если фамилии нет, то в колонке должно быть None # # **Заготовка кода** # ``` # df = pd.DataFrame([ # ["Stuart Bloom", False, 50], # ["Sheldon Cooper", True, 2000], # ["Amy Farrah Fowler", False, 1200], # ["Radjesh Koothrappali", False, 1000], # ["Howard Wolowitz", False, 1500], # ["Bernadette Rostenkowski", True, 2100], # ["Penny", True, 5000], # ["Leonard Hofstadter", True, 2500], # ], columns="name pythonista salary".split()) # # # тут ваш код # ``` # In[54]: # andrey-yantsen df['name'].apply(lambda x: '' if ' ' not in x else x.split(' ')[1]) # In[55]: def ln(i): names = i.split() try: return names[1] except IndexError: return names[0] df['name'].apply(ln) # In[56]: #khudyakovavi df['name'].apply(lambda x: None if ' ' not in x else ' '.join(x.split()[1:])) #





















#





















#





















# In[57]: def split_surname(name): parts = name.split(" ", 1) if len(parts) > 1: return parts[1] else: return None df['surname'] = df['name'].apply(split_surname) df # In[60]: # средняя зарплата питонистов и не питонистов df.groupby('pythonista').mean() # In[61]: # Стюарт и Пенни портят всю картину =) Попробуем медиану df.groupby('pythonista').median() # In[62]: # не доверяйте средним значениям за весь тест. Вы можете упустить выбросы, например, пики из за GC. sns.plt.scatter(df['pythonista'], df['salary']) # ## Загрузим лог в Pandas # # **вспомогательные материалы**: [Генераторы](https://wiki.python.org/moin/Generators) в python # # **код**: # # ``` # regex = re.compile('([\w\-\.@\'*,:/#&]+) - - \[(.*?)\] "([A-Z]+)\s+(\S+).*" (\d+) (-|\d+)') # delim = re.compile("[#?]") # # def parse_log_re(file): # for line in file: # try: # # заодно отделим параметры от урла # fields = regex.match(line).groups() # uri_parts = delim.split(fields[3], 1) # uri = uri_parts[0] # if len(uri_parts) > 1: # params = uri_parts[1] # else: # params = None # yield list(fields) + [uri, params] # except AttributeError: # pass # # columns = "host ts method uri code size url params".split() # with open("./access_log_Jul95", encoding="ascii", errors="replace") as datafile: # df = pd.DataFrame.from_records(parse_log_re(datafile), columns=columns) # ``` # In[63]: regex = re.compile('([\w\-\.@\'*,:/#&]+) - - \[(.*?)\] "([A-Z]+)\s+(\S+).*" (\d+) (-|\d+)') delim = re.compile("[#?]") def parse_log_re(file): for line in file: try: # заодно отделим параметры от урла fields = regex.match(line).groups() uri_parts = delim.split(fields[3], 1) uri = uri_parts[0] if len(uri_parts) > 1: params = uri_parts[1] else: params = None yield list(fields) + [uri, params] except AttributeError: pass columns = "host ts method uri code size url params".split() with open("./access_log_Jul95", encoding="ascii", errors="replace") as datafile: df = pd.DataFrame.from_records(parse_log_re(datafile), columns=columns) # In[64]: df.head() # In[65]: df.dtypes # In[67]: df["size"] = pd.to_numeric(df["size"], errors='coerce') df["ts"] = pd.to_datetime(df["ts"], format="%d/%b/%Y:%H:%M:%S -0400") # In[68]: df.dtypes # In[69]: df.head() # In[70]: # булевы операции в numpy a = np.array([True, True, False, False]) b = np.array([True, False, True, False]) print("~a:", ~a) print("a & b:", a & b) print("a | b:", a | b) # In[71]: # посмотрим, какие бывают параметры в урлах df[~df["params"].isnull()] # In[75]: # построим распределение размеров ответов # создаем оси, чтобы можно было задать размер fig, ax = sns.plt.subplots() # и задаем размер fig.set_size_inches(16,10) # в параметрах указываем оси df[["size"]].hist(bins=20, ax=ax) # In[76]: # логарифмический масштаб по оси y fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) df[["size"]].hist(bins=20, ax=ax) # задаем логарифмический размер ax.set_yscale("log") # ## Минизадачка 5: Посмотреть, что за пик # # **Задание** Посмотреть, какие запросы относятся к пику, который мы видим на графике. #





















#





















#





















# In[80]: #butorov df[df['size'] > 2000000] # In[77]: df[df["size"] > 2500000] # In[79]: # чтобы сделать логарифмический масштаб по оси x, нам нужны логарифмические бины # сгенерим их с помощью numpy.logspace fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) # обратите внимание на параметр bins df[["size"]].hist(bins=np.logspace(0.1, 10.0, 20), ax=ax) #df[["size"]].hist(bins=20, ax=ax) # логарифмический масштаб оси по x ax.set_xscale("log") # ## Задачка 6: считаем RPS на сервере # # **Задание**: найти число запросов в секунду для каждой секунды, когда они были # In[81]: # khudyakovavi df.groupby('ts').size() #





















#





















#





















# In[82]: rps = df.groupby("ts")[["method"]].count() rps.head() # In[83]: rps.max() # In[84]: # построим график RPS fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) rps.plot(ax=ax) # In[85]: # добавим скользящее среднее, чтобы сгладить пики fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) #rps.plot(ax=ax) # rolling сделает нам группы, используя скользящее окно, по которым можно считать разное roll = rps.rolling(window=600) roll.mean().plot(ax=ax, c="red") # ## Творческая задачка 7: исследовать пик # # **Задание**: узнать, с чем связан пик на графике # In[88]: #vanadium23 from datetime import datetime df[df['ts'] > datetime(1995, 7, 13)][df['ts'] < datetime(1995, 7, 15)].groupby('uri').count() #





















#





















#





















# In[89]: # для начала сравним на одном графике обычный и необычный дни # возьмем время без даты df['time'] = df["ts"].dt.time # и отсеим нужные нам данные event = df[ (df['ts'] > pd.Timestamp("1995-07-13 00:00:00")) & (df['ts'] < pd.Timestamp("1995-07-14 00:00:00")) ] no_event = df[ (df['ts'] > pd.Timestamp("1995-07-14 00:00:00")) & (df['ts'] < pd.Timestamp("1995-07-15 00:00:00")) ] # оси fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) # скользящее среднее для дня с событием event_rps = event.groupby("time")[["method"]].count() event_roll = event_rps.rolling(window=600) event_roll.mean().plot(ax=ax, c="red") # скользящее среднее для дня без события no_event_rps = no_event.groupby("time")[["method"]].count() no_event_roll = no_event_rps.rolling(window=600) no_event_roll.mean().plot(ax=ax, c="green") # In[90]: # урлы, на которые ходили в четверг, но не ходили в пятницу set(event['url']) - set(no_event['url']) # In[91]: # посмотрим на разницу в топ100 visits_event = event.groupby('url')[['method']].count().sort_values(by='method', ascending=False) visits_no_event = no_event.groupby('url')[['method']].count().sort_values(by='method', ascending=False) set(visits_event[:100].index) - set(visits_no_event[:100].index) # https://en.wikipedia.org/wiki/STS-70 # # Анализируем результаты стрельб Танком # In[92]: # функция для импорта сырого отчета phantom phout_columns = [ 'time', 'tag', 'interval_real', 'connect_time', 'send_time', 'latency', 'receive_time', 'interval_event', 'size_out', 'size_in', 'net_code', 'proto_code'] def read_phout(filename): data = pd.read_csv( filename, sep='\t', names=phout_columns) # хитрые манипуляции с колонками # в логе есть время отправки и время выполнения запроса. Суммируем их и получается # время получения ответа - будем группировать по нему (заводим новую колонку) data['ts'] = data.time + data.interval_real / 1000000 # округляем до секунды data['receive_sec'] = data.ts.astype(int) # и индексируем по этой секунде data.set_index(['receive_sec'], inplace=True) # для удобства посчитаем время ответа в миллисекундах data['rt_ms'] = data.interval_real / 1000 return data # In[93]: phout1 = read_phout("phout_1.log") phout2 = read_phout("phout_2.log") # In[94]: # графики RPS fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) phout1.groupby(level=0).count().time.rolling(window=10).mean().plot(title="RPS 1", ax=ax) fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) phout2.groupby(level=0).count().time.rolling(window=10).mean().plot(title="RPS 2", ax=ax) # In[95]: # группируем по индексу, считаем медиану, среднее и максимум и рисуем график fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) phout1.groupby(level=0).rt_ms.agg([np.mean, np.median, np.max]).plot(ax=ax) # In[96]: # медиана и среднее fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) phout1 = phout1[2000:-1000] phout1.groupby(level=0).rt_ms.agg([np.mean, np.median]).plot(ax=ax) # In[97]: # сглаженные медиана и среднее fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) phout1 = phout1[2000:-1000] phout1.groupby(level=0).rt_ms.agg([np.mean, np.median]).rolling(window=10).mean().plot(ax=ax) # In[98]: # сглаженные медиана, среднее и rps на одном графике для второго лога fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) phout2.groupby(level=0).count().time.rolling(window=10).mean().plot(title="RPS vs timings", ax=ax) phout2.groupby(level=0).rt_ms.agg([np.mean, np.median]).rolling(window=10).mean().plot(ax=ax, secondary_y=True) # In[99]: # сглаженные медиана, среднее и rps на одном графике для времен соединения, отправки, обработки и получения данных fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) phout2.groupby(level=0).count().time.rolling(window=10).mean().plot(title="RPS vs subtimings mean", ax=ax) (phout2[[ "interval_real", 'connect_time', 'send_time', 'latency', 'receive_time']] / 1000.0).groupby(level=0).agg(np.mean).rolling(window=10).mean().plot(ax=ax, secondary_y=True) # In[100]: # процентильный график def percentile(n): def percentile_(x): return np.percentile(x, n) percentile_.__name__ = 'percentile_%s' % n return percentile_ percentiles = [percentile(n) for n in [1, .75, .5, .25, 0]] fig, ax = sns.plt.subplots() fig.set_size_inches(16,10) phout2.groupby(level=0).count().time.rolling(window=10).mean().plot(title="RPS vs timings", ax=ax) phout2.groupby(level=0).rt_ms.agg(percentiles).rolling(window=10).mean().plot( title='Phout2 percentiles vs rps', kind='area', stacked=False, figsize=(12, 10), linewidth=0, ax=ax, secondary_y=True)